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 @@