Files
a_long_vue/src/views/chat/Agent.vue
2026-07-01 11:36:13 +08:00

544 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="agent-container">
<!-- 顶部操作栏 -->
<div class="agent-header">
<div class="header-left">
<el-tag :type="statusTagType" size="large">{{ statusText }}</el-tag>
<el-button
v-if="goeasyReady"
:type="isOnline ? 'danger' : 'primary'"
size="small"
@click="toggleOnline"
:loading="switching"
>
{{ isOnline ? '下线' : '上线' }}
</el-button>
<span class="online-tip" v-if="goeasyReady && isOnline">切换页面不会下线</span>
<span class="unread-tip" v-if="unreadTotal">
<i class="el-icon-chat-dot-round" style="color: red; margin-right: 4px;"></i>
{{ unreadTotal }}条未读
</span>
</div>
<div class="header-right">
<el-radio-group v-model="activeRole" size="small" @change="applyFilter">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button
v-for="r in availableRoles"
:key="r.prefix"
:label="r.prefix"
>{{ r.name }}</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 主体区域 -->
<div class="agent-body">
<div class="conversation-panel">
<el-tabs v-model="tabActive" class="conv-tabs">
<el-tab-pane :label="`待接入 (${pendingList.length})`" name="pending">
<div class="conv-list" v-if="pendingList.length">
<div
v-for="conv in pendingList"
:key="conv.id"
class="conv-item"
@click="acceptConversation(conv)"
>
<div class="conv-avatar">
<el-avatar :size="36" :src="conv.data?.avatar || defaultAvatar" />
</div>
<div class="conv-info">
<div class="conv-name">{{ conv.data?.name || conv.id }}</div>
<div class="conv-preview">待接入...</div>
</div>
<el-button type="success" size="small" plain>接听</el-button>
</div>
</div>
<el-empty v-else description="暂无待接入会话" />
</el-tab-pane>
<el-tab-pane :label="`已接入 (${activeList.length})`" name="active">
<div class="conv-list" v-if="activeList.length">
<div
v-for="conv in activeList"
:key="conv.id"
class="conv-item"
@click="openChat(conv)"
>
<div class="conv-avatar">
<el-avatar :size="36" :src="conv.data?.avatar || defaultAvatar" />
</div>
<div class="conv-info">
<div class="conv-name">{{ conv.data?.name || conv.id }}</div>
<div class="conv-preview">{{ conv.lastMessage?.payload?.text || '[其他消息]' }}</div>
</div>
<div class="conv-actions">
<span class="unread-badge" v-if="conv.unread">{{ conv.unread }}</span>
<el-button type="warning" size="small" plain @click.stop="endConversation(conv)">
结束
</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无已接入会话" />
</el-tab-pane>
</el-tabs>
</div>
<!-- 右侧主动搜索用户 -->
<div class="search-panel">
<div class="search-title">主动联系用户</div>
<el-select
v-model="searchRole"
placeholder="选择身份"
size="small"
style="width: 100%; margin-bottom: 10px"
>
<el-option
v-for="r in availableRoles"
:key="r.prefix"
:label="r.name"
:value="r.prefix"
/>
</el-select>
<el-input
v-model="searchUid"
placeholder="输入用户UID纯数字"
size="small"
class="search-input"
>
<template #append>
<el-button :disabled="!searchRole || !searchUid" @click="startNewChat">发起会话</el-button>
</template>
</el-input>
<div class="search-tip">示例选择打手输入123456将联系 Ds123456</div>
</div>
</div>
<!-- 聊天弹窗 -->
<ChatDialog
v-if="dialogVisible"
v-model:visible="dialogVisible"
:team-id="teamId"
:customer-id="currentCustomerId"
:customer-data="currentCustomerData"
:agent-online="isOnline"
@close="currentCustomerId = ''"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
import { ElMessage } from 'element-plus'
import ChatDialog from '@/components/ChatDialog.vue'
import GoEasy from 'goeasy'
import {
KEFU_TEAM_ID,
getCsteam,
refreshKefuUnread,
calcUnreadFromConversations,
ensureCustomerAccepted,
goOnlineManually,
goOfflineManually,
isGoeasyConnected,
checkCsOnlineStatus
} from '@/utils/kefuChat'
const { proxy } = getCurrentInstance()
const availableRoles = [
{ prefix: 'Ds', name: '打手' },
{ prefix: 'Boss', name: '老板' },
{ prefix: 'Gs', name: '管事' },
{ prefix: 'Sj', name: '商家' },
{ prefix: 'Zz', name: '组长' }
]
const goeasyReady = ref(isGoeasyConnected())
const isOnline = ref(!!window.__kefuCsOnline)
const switching = ref(false)
const tabActive = ref('pending')
const pendingList = ref([])
const activeList = ref([])
const unreadTotal = ref(0)
const teamId = KEFU_TEAM_ID
const searchRole = ref('')
const searchUid = ref('')
const dialogVisible = ref(false)
const currentCustomerId = ref('')
const currentCustomerData = ref({ name: '', avatar: '' })
const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
const activeRole = ref('all')
const statusText = computed(() => {
if (!goeasyReady.value) return '消息连接中…'
return isOnline.value ? '在线接待中' : '已离线'
})
const statusTagType = computed(() => {
if (!goeasyReady.value) return 'info'
return isOnline.value ? 'success' : 'warning'
})
function getCsteamLocal() {
return getCsteam()
}
// 过滤
function filterConversations(convs) {
if (activeRole.value === 'all') return convs
return convs.filter(c => {
const userId = c.userId || c.id || ''
return userId.startsWith(activeRole.value)
})
}
const applyFilter = () => {
fetchPendingConversations()
fetchActiveConversations()
}
// 监听绑定(连接成功后立即执行)
let conversationsUpdatedHandler = null
let pendingUpdatedHandler = null
function bindConversationListeners() {
// 先清旧
if (conversationsUpdatedHandler) {
window.goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
}
if (pendingUpdatedHandler) {
window.goeasy.im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
}
conversationsUpdatedHandler = (data) => {
const all = data.conversations || []
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
unreadTotal.value = data.unreadTotal || calcUnreadFromConversations(activeList.value)
refreshKefuUnread(proxy.$emitter)
}
window.goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
pendingUpdatedHandler = (data) => {
pendingList.value = filterConversations(data.conversations || [])
}
window.goeasy.im.on(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
}
// 连接成功回调
const onConnected = () => {
console.log('[Agent] 收到连接成功事件,立刻绑定会话监听')
bindConversationListeners()
fetchPendingConversations()
fetchActiveConversations()
}
const toggleOnline = () => {
if (switching.value || !goeasyReady.value) {
ElMessage.warning('消息服务尚未连接,请稍候…')
return
}
switching.value = true
const phone = localStorage.getItem('username')
const stopSpin = () => { switching.value = false }
const safetyTimer = setTimeout(stopSpin, 15000)
const wrapDone = (fn) => (...args) => {
clearTimeout(safetyTimer)
stopSpin()
fn?.(...args)
}
if (isOnline.value) {
goOfflineManually({
emitter: proxy.$emitter,
onSuccess: wrapDone(() => {
isOnline.value = false
ElMessage.success('已下线,不再接收新会话')
refreshKefuUnread(proxy.$emitter)
}),
onFailed: wrapDone((error) => {
ElMessage.error('下线失败:' + (error?.content || '未知错误'))
})
})
} else {
goOnlineManually({
phone,
emitter: proxy.$emitter,
onSuccess: wrapDone(() => {
isOnline.value = true
ElMessage.success('已上线,开始接收会话')
fetchPendingConversations()
fetchActiveConversations()
refreshKefuUnread(proxy.$emitter)
}),
onFailed: wrapDone((error) => {
ElMessage.error('上线失败:' + (error?.content || '未知错误'))
})
})
}
}
const fetchPendingConversations = () => {
if (!window.goeasy) return
window.goeasy.im.pendingConversations({
onSuccess: (result) => {
pendingList.value = filterConversations(result.content?.conversations || [])
}
})
}
// 拉取已接入
const fetchActiveConversations = () => {
if (!window.goeasy) return
window.goeasy.im.latestConversations({
onSuccess: (result) => {
const all = result.content?.conversations || []
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
}
})
}
const acceptAndOpenChat = async (conv, openDialog = true) => {
if (activeList.value.length >= 20) {
ElMessage.warning('最多同时接入20个会话')
return
}
if (!goeasyReady.value) {
ElMessage.warning('消息服务连接中,请稍候…')
return
}
if (!isOnline.value) {
ElMessage.warning('请先上线后再接入会话')
return
}
const customerId = conv.id || conv.userId
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
try {
await ensureCustomerAccepted(customerId, customerData)
ElMessage.success('已接入')
fetchPendingConversations()
fetchActiveConversations()
if (openDialog) {
currentCustomerId.value = customerId
currentCustomerData.value = customerData
dialogVisible.value = true
tabActive.value = 'active'
}
} catch (error) {
ElMessage.error('接入失败:' + (error?.content || '未知错误'))
}
}
const acceptConversation = (conv) => {
acceptAndOpenChat(conv, true)
}
const endConversation = (conv) => {
const csteam = getCsteamLocal()
csteam.end({
id: conv.id,
onSuccess: () => {
ElMessage.success('会话已结束')
fetchActiveConversations()
},
onFailed: (error) => {
ElMessage.error('结束失败:' + error.content)
}
})
}
const openChat = async (conv) => {
const customerId = conv.id || conv.userId
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
if (!goeasyReady.value) {
ElMessage.warning('消息服务连接中,请稍候…')
return
}
if (!isOnline.value) {
ElMessage.warning('请先上线后再接入会话')
return
}
try {
await ensureCustomerAccepted(customerId, customerData)
currentCustomerId.value = customerId
currentCustomerData.value = customerData
dialogVisible.value = true
} catch (error) {
ElMessage.error('接入会话失败:' + (error?.content || '未知错误'))
}
}
const startNewChat = () => {
if (!searchRole.value || !searchUid.value) return
if (!goeasyReady.value) {
ElMessage.warning('消息服务连接中,请稍候…')
return
}
if (!isOnline.value) {
ElMessage.warning('请先上线后再接入会话')
return
}
const uid = String(searchUid.value).trim()
if (!/^\d+$/.test(uid)) {
ElMessage.warning('UID 请输入纯数字')
return
}
const fullId = searchRole.value + uid
const active = activeList.value.find(c => (c.id === fullId || c.userId === fullId))
if (active) {
openChat(active)
return
}
const pending = pendingList.value.find(c => c.id === fullId)
if (pending) {
acceptAndOpenChat(pending, true)
return
}
acceptAndOpenChat({
id: fullId,
data: { name: fullId, avatar: defaultAvatar }
}, true)
}
const onAgentUnread = (count) => {
unreadTotal.value = count
}
const onGoeasyReady = (ready) => {
goeasyReady.value = !!ready
}
const onCsOnlineChange = (online) => {
isOnline.value = !!online
}
const onGoEasyConnected = () => {
onConnected()
goeasyReady.value = isGoeasyConnected()
onCsOnlineChange(window.__kefuCsOnline)
}
onMounted(async () => {
goeasyReady.value = isGoeasyConnected()
if (goeasyReady.value) {
const online = await checkCsOnlineStatus()
isOnline.value = online
proxy.$emitter.emit('kefu-cs-online', online)
} else {
isOnline.value = !!window.__kefuCsOnline
}
proxy.$emitter.on('kefu-goeasy-ready', onGoeasyReady)
proxy.$emitter.on('kefu-cs-online', onCsOnlineChange)
proxy.$emitter.on('agent-unread', onAgentUnread)
if (window.goeasy && isGoeasyConnected()) {
onConnected()
} else {
proxy.$emitter.on('goeasy-connected', onGoEasyConnected)
}
})
onUnmounted(() => {
proxy.$emitter.off('goeasy-connected', onGoEasyConnected)
proxy.$emitter.off('kefu-goeasy-ready', onGoeasyReady)
proxy.$emitter.off('kefu-cs-online', onCsOnlineChange)
proxy.$emitter.off('agent-unread', onAgentUnread)
if (window.goeasy && conversationsUpdatedHandler) {
window.goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
}
if (window.goeasy && pendingUpdatedHandler) {
window.goeasy.im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
}
})
</script>
<style scoped>
.agent-container {
height: 100%;
display: flex;
flex-direction: column;
color: #eef2ff;
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.unread-tip {
color: #ff4d4f;
font-weight: bold;
}
.agent-body {
flex: 1;
display: flex;
gap: 16px;
}
.conversation-panel {
flex: 2;
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 12px;
}
.search-panel {
flex: 1;
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 12px;
}
.conv-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
cursor: pointer;
transition: background 0.2s;
}
.conv-item:hover {
background: rgba(0,242,255,0.1);
}
.conv-info {
flex: 1;
overflow: hidden;
}
.conv-name {
font-weight: 500;
}
.conv-preview {
font-size: 12px;
color: #aaa;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.unread-badge {
background: #ff4d4f;
color: white;
border-radius: 10px;
padding: 0 6px;
margin-right: 8px;
font-size: 12px;
}
.search-tip {
font-size: 12px;
color: #888;
margin-top: 8px;
}
.online-tip {
font-size: 12px;
color: #8bc34a;
}
</style>