diff --git a/src/components/ChatDialog.vue b/src/components/ChatDialog.vue
index 9c618df..56465fc 100644
--- a/src/components/ChatDialog.vue
+++ b/src/components/ChatDialog.vue
@@ -5,7 +5,7 @@
width="700px"
:close-on-click-modal="false"
destroy-on-close
- @open="onDialogOpen"
+ @opened="onDialogOpened"
@close="onDialogClose"
>
@@ -54,7 +54,7 @@
- 发送
+ 发送
@@ -63,16 +63,24 @@
diff --git a/src/components/KefuConnect.vue b/src/components/KefuConnect.vue
index c1b10e1..8062ef4 100644
--- a/src/components/KefuConnect.vue
+++ b/src/components/KefuConnect.vue
@@ -3,91 +3,101 @@
\ No newline at end of file
+
diff --git a/src/utils/kefuChat.js b/src/utils/kefuChat.js
new file mode 100644
index 0000000..8998a85
--- /dev/null
+++ b/src/utils/kefuChat.js
@@ -0,0 +1,398 @@
+import GoEasy from 'goeasy'
+
+export const KEFU_TEAM_ID = 'support_team'
+
+const NOTIFY_DEBOUNCE_MS = 2500
+const ONLINE_TIMEOUT_MS = 12000
+
+let lastNotifyAt = 0
+let onlineInFlight = null
+
+export function getCsteam() {
+ if (!window.goeasy) return null
+ return window.goeasy.im.csteam(KEFU_TEAM_ID)
+}
+
+export function isGoeasyConnected() {
+ if (!window.goeasy) return false
+ try {
+ const status = window.goeasy.getConnectionStatus()
+ return status === 'connected' || status === 'reconnected'
+ } catch (e) {
+ return !!window.goeasy
+ }
+}
+
+export function calcUnreadFromConversations(conversations = []) {
+ return conversations.reduce((sum, c) => sum + (c.unread || 0), 0)
+}
+
+/** 查询服务端真实在线状态 */
+export function checkCsOnlineStatus() {
+ return new Promise((resolve) => {
+ const csteam = getCsteam()
+ if (!csteam) {
+ resolve(false)
+ return
+ }
+ csteam.isOnline({
+ onSuccess: (online) => {
+ window.__kefuCsOnline = !!online
+ resolve(!!online)
+ },
+ onFailed: () => resolve(false)
+ })
+ })
+}
+
+/** 拉取已接入 + 待接入会话未读总数,并触发 agent-unread */
+export function refreshKefuUnread(emitter) {
+ if (!window.goeasy) return
+ const im = window.goeasy.im
+ let activeUnread = 0
+
+ im.latestConversations({
+ onSuccess: (result) => {
+ const content = result.content || {}
+ const convs = content.conversations || []
+ const csActive = convs.filter(c => c.type === 'cs' && !c.ended)
+ activeUnread = content.unreadTotal || calcUnreadFromConversations(csActive)
+
+ im.pendingConversations({
+ onSuccess: (pendingResult) => {
+ const pending = pendingResult.content?.conversations || []
+ const pendingUnread = calcUnreadFromConversations(pending)
+ emitter?.emit('agent-unread', activeUnread + pendingUnread)
+ },
+ onFailed: () => {
+ emitter?.emit('agent-unread', activeUnread)
+ }
+ })
+ },
+ onFailed: () => {}
+ })
+}
+
+function emitCsOnline(emitter, online) {
+ window.__kefuCsOnline = !!online
+ emitter?.emit('kefu-cs-online', window.__kefuCsOnline)
+}
+
+export function ensureCsOnline({ phone, onSuccess, onFailed, emitter, force = false } = {}) {
+ if (!window.goeasy) {
+ onFailed?.({ content: '消息服务未连接' })
+ return
+ }
+ const csteam = getCsteam()
+ if (!csteam) {
+ onFailed?.({ content: '客服团队未初始化' })
+ return
+ }
+
+ const finishSuccess = () => {
+ emitCsOnline(emitter, true)
+ onSuccess?.()
+ }
+ const finishFailed = (error) => {
+ emitCsOnline(emitter, false)
+ onFailed?.(error)
+ }
+
+ // 非强制时:先查服务端是否已在线,避免重复 online 卡死
+ if (!force && window.__kefuCsOnline) {
+ checkCsOnlineStatus().then((online) => {
+ if (online) {
+ finishSuccess()
+ } else {
+ window.__kefuCsOnline = false
+ doCsOnline(csteam, phone, emitter, finishSuccess, finishFailed)
+ }
+ })
+ return
+ }
+
+ if (force) {
+ window.__kefuCsOnline = false
+ }
+
+ doCsOnline(csteam, phone, emitter, finishSuccess, finishFailed)
+}
+
+function doCsOnline(csteam, phone, emitter, onSuccess, onFailed) {
+ if (onlineInFlight) {
+ onlineInFlight
+ .then(() => onSuccess?.())
+ .catch((e) => onFailed?.(e))
+ return
+ }
+
+ const displayName = phone || localStorage.getItem('username') || '客服'
+ let finished = false
+
+ const timer = setTimeout(() => {
+ if (finished) return
+ finished = true
+ onlineInFlight = null
+ emitCsOnline(emitter, false)
+ onFailed?.({ content: '上线超时,请检查网络后重试' })
+ }, ONLINE_TIMEOUT_MS)
+
+ onlineInFlight = new Promise((resolve, reject) => {
+ csteam.online({
+ teamData: { name: '阿龙电竞客服中心', avatar: '' },
+ agentData: { name: displayName, avatar: '' },
+ onSuccess: () => {
+ if (finished) return
+ finished = true
+ clearTimeout(timer)
+ onlineInFlight = null
+ emitCsOnline(emitter, true)
+ resolve(true)
+ },
+ onFailed: (error) => {
+ if (finished) return
+ finished = true
+ clearTimeout(timer)
+ onlineInFlight = null
+ // 已在线视为成功
+ const msg = (error?.content || '').toLowerCase()
+ if (msg.includes('online') || msg.includes('已') || msg.includes('already')) {
+ emitCsOnline(emitter, true)
+ resolve(true)
+ return
+ }
+ emitCsOnline(emitter, false)
+ reject(error)
+ }
+ })
+ })
+
+ onlineInFlight
+ .then(() => onSuccess?.())
+ .catch((e) => onFailed?.(e))
+}
+
+/** 用户手动上线 */
+export function goOnlineManually({ phone, emitter, onSuccess, onFailed } = {}) {
+ window.__kefuUserWantsOffline = false
+ ensureCsOnline({
+ phone,
+ emitter,
+ force: true,
+ onSuccess,
+ onFailed
+ })
+}
+
+/** 用户手动下线(不断开 GoEasy) */
+export function goOfflineManually({ emitter, onSuccess, onFailed } = {}) {
+ window.__kefuUserWantsOffline = true
+ onlineInFlight = null
+ const csteam = getCsteam()
+ if (!csteam) {
+ emitCsOnline(emitter, false)
+ onSuccess?.()
+ return
+ }
+ csteam.offline({
+ onSuccess: () => {
+ emitCsOnline(emitter, false)
+ onSuccess?.()
+ },
+ onFailed: (error) => onFailed?.(error)
+ })
+}
+
+export function disconnectKefuChat() {
+ onlineInFlight = null
+ if (window.__kefuUnreadCleanup) {
+ window.__kefuUnreadCleanup()
+ window.__kefuUnreadCleanup = null
+ }
+ if (window.goeasy) {
+ try {
+ window.goeasy.disconnect({})
+ } catch (e) {
+ // ignore
+ }
+ window.goeasy = null
+ }
+ window.__kefuCsOnline = false
+ window.__kefuUserWantsOffline = false
+ window.__kefuAgentId = null
+ window.__kefuAppkey = null
+}
+
+export function getCsMessageCustomerId(message, teamId = KEFU_TEAM_ID, agentId) {
+ if (!message) return null
+ const tid = message.teamId || teamId
+ const aid = agentId || window.__kefuAgentId
+ const sender = message.senderId
+ const to = message.to || message.receiverId
+
+ try {
+ if (typeof message.customerId === 'function') {
+ const cid = message.customerId()
+ if (cid && cid !== tid) return cid
+ }
+ } catch (e) {
+ // ignore
+ }
+
+ const parties = [sender, to].filter(Boolean)
+ for (const p of parties) {
+ if (p !== tid && p !== aid && !String(p).startsWith('KF')) return p
+ }
+ if (sender === aid && to) return to
+ if (to === aid && sender) return sender
+ if (sender === tid && to) return to
+ if (to === tid && sender) return sender
+ return null
+}
+
+export function isCsMessageForCustomer(message, customerId, teamId = KEFU_TEAM_ID, agentId) {
+ if (!message || !customerId) return false
+ if (message.teamId && message.teamId !== teamId) return false
+ const cid = getCsMessageCustomerId(message, teamId, agentId)
+ return cid === customerId
+}
+
+export function buildCsTextMessage(teamId, customerId, customerData, text) {
+ const message = window.goeasy.im.createTextMessage({
+ text,
+ to: {
+ type: GoEasy.IM_SCENE.CS,
+ id: customerId,
+ data: customerData || { name: customerId, avatar: '' }
+ }
+ })
+ message.teamId = teamId
+ message.accepted = true
+ return message
+}
+
+export function ensureCustomerAccepted(customerId, customerData) {
+ return new Promise((resolve, reject) => {
+ if (!window.goeasy || !customerId) {
+ reject({ content: '连接未就绪' })
+ return
+ }
+ if (!window.__kefuCsOnline) {
+ reject({ content: '请先上线后再聊天' })
+ return
+ }
+ const csteam = getCsteam()
+ if (!csteam) {
+ reject({ content: '客服团队未初始化' })
+ return
+ }
+ csteam.accept({
+ customer: {
+ id: customerId,
+ data: customerData || { name: customerId, avatar: '' }
+ },
+ onSuccess: () => resolve(),
+ onFailed: (error) => {
+ const msg = (error?.content || '').toLowerCase()
+ if (msg.includes('already') || msg.includes('exist') || msg.includes('已')) {
+ resolve()
+ return
+ }
+ reject(error)
+ }
+ })
+ })
+}
+
+function maybeNotifyNewMessage(emitter, message) {
+ const aid = window.__kefuAgentId
+ if (message?.senderId && message.senderId === aid) return
+
+ const now = Date.now()
+ if (now - lastNotifyAt < NOTIFY_DEBOUNCE_MS) return
+ lastNotifyAt = now
+
+ const customerId = getCsMessageCustomerId(message)
+ const preview = message?.payload?.text || message?.payload?.name || '[新消息]'
+ emitter?.emit('kefu-message-notify', {
+ customerId,
+ preview: String(preview).slice(0, 80),
+ message
+ })
+}
+
+function setupGlobalListeners(emitter) {
+ if (!window.goeasy) return () => {}
+
+ const im = window.goeasy.im
+ const onRefresh = () => refreshKefuUnread(emitter)
+ const onCsMessage = (message) => {
+ emitter?.emit('kefu-cs-new-message', message)
+ maybeNotifyNewMessage(emitter, message)
+ refreshKefuUnread(emitter)
+ }
+
+ im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onRefresh)
+ im.on(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, onRefresh)
+ im.on(GoEasy.IM_EVENT.CS_MESSAGE_RECEIVED, onCsMessage)
+
+ refreshKefuUnread(emitter)
+
+ return () => {
+ im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onRefresh)
+ im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, onRefresh)
+ im.off(GoEasy.IM_EVENT.CS_MESSAGE_RECEIVED, onCsMessage)
+ }
+}
+
+export function bindGlobalUnreadListeners(emitter) {
+ return setupGlobalListeners(emitter)
+}
+
+/** GoEasy 连上后:绑定监听,未手动下线则自动上线 */
+export function afterGoeasyConnected(emitter, phone) {
+ if (window.__kefuUnreadCleanup) {
+ window.__kefuUnreadCleanup()
+ }
+ window.__kefuUnreadCleanup = bindGlobalUnreadListeners(emitter)
+ refreshKefuUnread(emitter)
+
+ if (window.__kefuUserWantsOffline) {
+ emitCsOnline(emitter, false)
+ return
+ }
+
+ ensureCsOnline({
+ phone,
+ emitter,
+ force: false,
+ onSuccess: () => refreshKefuUnread(emitter),
+ onFailed: () => refreshKefuUnread(emitter)
+ })
+}
+
+export function resumeKefuSession(emitter, phone) {
+ if (!isGoeasyConnected()) {
+ emitter?.emit('kefu-goeasy-ready', false)
+ return
+ }
+ emitter?.emit('kefu-goeasy-ready', true)
+ afterGoeasyConnected(emitter, phone)
+}
+
+/** 重连成功后恢复(不切页下线) */
+export function onGoeasyReconnected(emitter, phone) {
+ emitter?.emit('kefu-goeasy-ready', true)
+ if (window.__kefuUserWantsOffline) {
+ emitCsOnline(emitter, false)
+ refreshKefuUnread(emitter)
+ return
+ }
+ ensureCsOnline({
+ phone,
+ emitter,
+ force: false,
+ onSuccess: () => refreshKefuUnread(emitter),
+ onFailed: () => refreshKefuUnread(emitter)
+ })
+}
diff --git a/src/views/Login.vue b/src/views/Login.vue
index aafaa50..de1a9ee 100644
--- a/src/views/Login.vue
+++ b/src/views/Login.vue
@@ -75,10 +75,27 @@ const form = reactive({
ip: ''
})
+function isStoredTokenUsable(token) {
+ if (!token || typeof token !== 'string') return false
+ const parts = token.split('.')
+ if (parts.length !== 3) return false
+ try {
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
+ return Boolean(payload.yonghuid || payload.user_id)
+ } catch {
+ return false
+ }
+}
+
onMounted(() => {
const token = localStorage.getItem('token')
- if (token) {
+ if (isStoredTokenUsable(token)) {
router.push('/')
+ return
+ }
+ if (token) {
+ localStorage.removeItem('token')
+ localStorage.removeItem('username')
}
})
diff --git a/src/views/chat/Agent.vue b/src/views/chat/Agent.vue
index b2da6c3..2fd16f1 100644
--- a/src/views/chat/Agent.vue
+++ b/src/views/chat/Agent.vue
@@ -1,15 +1,11 @@
-
-
-