544 lines
15 KiB
Vue
544 lines
15 KiB
Vue
<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> |