fix: 登录页校验JWT有效性;同步后台多项功能更新
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@open="onDialogOpen"
|
||||
@opened="onDialogOpened"
|
||||
@close="onDialogClose"
|
||||
>
|
||||
<div class="chat-container">
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="input-area">
|
||||
<el-input v-model="inputText" placeholder="请输入消息..." @keyup.enter="sendText" size="small">
|
||||
<template #append>
|
||||
<el-button @click="sendText" :disabled="!inputText.trim()">发送</el-button>
|
||||
<el-button @click="sendText" :disabled="!inputText.trim() || !agentOnline">发送</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
@@ -63,16 +63,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
isCsMessageForCustomer,
|
||||
buildCsTextMessage,
|
||||
ensureCustomerAccepted
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
teamId: String,
|
||||
customerId: String,
|
||||
customerData: Object
|
||||
customerData: Object,
|
||||
agentOnline: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
@@ -97,8 +105,38 @@ const isSelf = (msg) => msg.senderId === agentId.value
|
||||
|
||||
let csteam = null
|
||||
let unlisten = null
|
||||
let sessionReady = false
|
||||
|
||||
// ----- 使用 listenCustomer 监听客户消息(GoEasy 官方方案) -----
|
||||
const pushMessage = (message) => {
|
||||
if (!message?.messageId) return
|
||||
if (messages.value.some(m => m.messageId === message.messageId)) return
|
||||
message.showTime = shouldShowTime(message.timestamp)
|
||||
messages.value.push(message)
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
}
|
||||
|
||||
const handleIncomingMessage = (message) => {
|
||||
if (!isCsMessageForCustomer(message, props.customerId, props.teamId, agentId.value)) return
|
||||
console.log('[ChatDialog] 收到实时消息:', message)
|
||||
const tempMsg = messages.value.find(
|
||||
m => m._tempId && m.payload?.text === message.payload?.text && m.senderId === agentId.value
|
||||
)
|
||||
if (tempMsg && message.senderId === agentId.value) {
|
||||
tempMsg.messageId = message.messageId
|
||||
tempMsg.status = 'success'
|
||||
tempMsg.timestamp = message.timestamp
|
||||
tempMsg._tempId = null
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
return
|
||||
}
|
||||
pushMessage(message)
|
||||
}
|
||||
|
||||
const onGlobalCsMessage = (message) => {
|
||||
handleIncomingMessage(message)
|
||||
}
|
||||
const startListenCustomer = () => {
|
||||
if (!window.goeasy || !props.teamId || !props.customerId) return
|
||||
csteam = window.goeasy.im.csteam(props.teamId)
|
||||
@@ -110,24 +148,7 @@ const startListenCustomer = () => {
|
||||
csteam.listenCustomer({
|
||||
id: props.customerId,
|
||||
onNewMessage: (message) => {
|
||||
console.log('[ChatDialog] 客户新消息:', message)
|
||||
// 避免与临时消息重复
|
||||
const tempMsg = messages.value.find(m => m._tempId && m.payload?.text === message.payload?.text)
|
||||
if (tempMsg && message.status === 'success') {
|
||||
tempMsg.messageId = message.messageId
|
||||
tempMsg.status = 'success'
|
||||
tempMsg.timestamp = message.timestamp
|
||||
tempMsg._tempId = null
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
return
|
||||
}
|
||||
// 去重
|
||||
if (messages.value.some(m => m.messageId === message.messageId)) return
|
||||
message.showTime = shouldShowTime(message.timestamp)
|
||||
messages.value.push(message)
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
handleIncomingMessage(message)
|
||||
},
|
||||
onStatusUpdated: (status) => {
|
||||
console.log('[ChatDialog] 客户状态更新:', status)
|
||||
@@ -190,23 +211,28 @@ const refreshHistory = () => {
|
||||
}
|
||||
|
||||
// ----- 发送消息 -----
|
||||
const sendText = () => {
|
||||
const sendText = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text) return
|
||||
if (!window.goeasy) return ElMessage.error('聊天服务未连接')
|
||||
|
||||
const message = window.goeasy.im.createTextMessage({
|
||||
text: text,
|
||||
to: {
|
||||
type: 'cs',
|
||||
id: props.customerId,
|
||||
data: props.customerData || { name: props.customerId, avatar: '' }
|
||||
if (!props.agentOnline && !window.__kefuCsOnline) {
|
||||
ElMessage.warning('客服正在连接,请稍候…')
|
||||
return
|
||||
}
|
||||
if (!sessionReady) {
|
||||
try {
|
||||
await ensureCustomerAccepted(props.customerId, props.customerData)
|
||||
sessionReady = true
|
||||
} catch (error) {
|
||||
ElMessage.error('会话未接入:' + (error?.content || '未知错误'))
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
message.teamId = props.teamId
|
||||
const customerData = props.customerData || { name: props.customerId, avatar: '' }
|
||||
const message = buildCsTextMessage(props.teamId, props.customerId, customerData, text)
|
||||
|
||||
console.log('[ChatDialog] 发送消息:', message)
|
||||
console.log('[ChatDialog] 发送消息 → 用户ID:', props.customerId, message)
|
||||
|
||||
const tempId = 'local-' + Date.now()
|
||||
const tempMsg = {
|
||||
@@ -271,23 +297,36 @@ const markRead = () => {
|
||||
}
|
||||
const previewImage = (url) => window.open(url)
|
||||
|
||||
// ----- 弹窗生命周期 -----
|
||||
const onDialogOpen = () => {
|
||||
console.log('[ChatDialog] 弹窗打开')
|
||||
// ----- 弹窗生命周期(@opened 确保 DOM/props 就绪) -----
|
||||
const onDialogOpened = async () => {
|
||||
console.log('[ChatDialog] 弹窗已打开, customerId=', props.customerId)
|
||||
if (!agentId.value) agentId.value = window.__kefuAgentId || 'KF' + localStorage.getItem('username')
|
||||
// 先监听客户,再加载历史
|
||||
sessionReady = false
|
||||
proxy.$emitter.on('kefu-cs-new-message', onGlobalCsMessage)
|
||||
|
||||
try {
|
||||
await ensureCustomerAccepted(
|
||||
props.customerId,
|
||||
props.customerData || { name: props.customerId, avatar: '' }
|
||||
)
|
||||
sessionReady = true
|
||||
} catch (error) {
|
||||
ElMessage.warning('接入会话:' + (error?.content || '请稍后重试'))
|
||||
}
|
||||
|
||||
startListenCustomer()
|
||||
loadHistory()
|
||||
}
|
||||
const onDialogClose = () => {
|
||||
// 关闭弹窗时不需要主动取消 listenCustomer,因为我们会重新 listen
|
||||
// 但可以清理 unlisten 引用
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
sessionReady = false
|
||||
messages.value = []
|
||||
lastTimestamp.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,91 +3,101 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { onMounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
afterGoeasyConnected,
|
||||
resumeKefuSession,
|
||||
onGoeasyReconnected,
|
||||
isGoeasyConnected
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits(['connected'])
|
||||
|
||||
// 每次进入页面都重新获取权限并建立连接
|
||||
async function connect() {
|
||||
console.log('[KefuConnect] 开始获取客服权限...')
|
||||
const phone = localStorage.getItem('username')
|
||||
if (!phone) {
|
||||
console.error('[KefuConnect] 未找到手机号')
|
||||
return
|
||||
}
|
||||
if (!phone) return
|
||||
|
||||
try {
|
||||
const res = await request.post('/houtai/kfhqltqx', { phone })
|
||||
if (res.code !== 0) {
|
||||
console.warn('[KefuConnect] 接口返回非0:', res)
|
||||
return
|
||||
}
|
||||
if (res.code !== 0) return
|
||||
|
||||
const { permissions, goeasy_appkey } = res.data
|
||||
console.log('[KefuConnect] 权限码:', permissions)
|
||||
console.log('[KefuConnect] GoEasy appkey:', goeasy_appkey)
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
console.warn('[KefuConnect] 无聊天权限,不能连接')
|
||||
ElMessage.warning('您没有客服聊天权限')
|
||||
return
|
||||
}
|
||||
if (!goeasy_appkey) {
|
||||
console.warn('[KefuConnect] 未获取到 appkey')
|
||||
return
|
||||
}
|
||||
|
||||
// 断开旧连接
|
||||
if (window.goeasy) {
|
||||
console.log('[KefuConnect] 断开旧连接...')
|
||||
try { window.goeasy.disconnect({}) } catch (e) {}
|
||||
window.goeasy = null
|
||||
}
|
||||
if (!permissions?.length || !goeasy_appkey) return
|
||||
|
||||
const agentId = 'KF' + phone
|
||||
console.log('[KefuConnect] 客服ID:', agentId)
|
||||
|
||||
// 创建新实例
|
||||
if (
|
||||
window.goeasy &&
|
||||
window.__kefuAgentId === agentId &&
|
||||
window.__kefuAppkey === goeasy_appkey &&
|
||||
isGoeasyConnected()
|
||||
) {
|
||||
resumeKefuSession(proxy.$emitter, phone)
|
||||
emit('connected')
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
return
|
||||
}
|
||||
|
||||
if (window.goeasy) {
|
||||
try { window.goeasy.disconnect({}) } catch (e) {}
|
||||
window.goeasy = null
|
||||
window.__kefuCsOnline = false
|
||||
}
|
||||
|
||||
window.__kefuAppkey = goeasy_appkey
|
||||
if (window.__kefuUserWantsOffline === undefined) {
|
||||
window.__kefuUserWantsOffline = false
|
||||
}
|
||||
|
||||
const goeasy = GoEasy.getInstance({
|
||||
host: 'hangzhou.goeasy.io', // 根据实际区域修改
|
||||
host: 'hangzhou.goeasy.io',
|
||||
appkey: goeasy_appkey,
|
||||
modules: ['im']
|
||||
})
|
||||
console.log('[KefuConnect] GoEasy 实例创建成功,准备连接...')
|
||||
|
||||
goeasy.connect({
|
||||
id: agentId,
|
||||
data: { name: phone, avatar: '' },
|
||||
onSuccess: () => {
|
||||
console.log('[KefuConnect] ✅ 连接成功!')
|
||||
window.goeasy = goeasy
|
||||
window.__kefuAgentId = agentId
|
||||
// 通知父组件(Agent.vue)可以进行上线操作
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', true)
|
||||
afterGoeasyConnected(proxy.$emitter, phone)
|
||||
emit('connected')
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[KefuConnect] ❌ 连接失败:', error)
|
||||
ElMessage.error('消息服务连接失败,请重试')
|
||||
console.error('[KefuConnect] 连接失败:', error)
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||
ElMessage.error('消息服务连接失败,请刷新重试')
|
||||
},
|
||||
onProgress: (attempts) => {
|
||||
console.log('[KefuConnect] 🔄 重连中... 第' + attempts + '次')
|
||||
// 重连心跳:不要把 ready 置 false,否则上线按钮会卡死
|
||||
console.log('[KefuConnect] 重连中…', attempts)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听重连成功(GoEasy 2.x)
|
||||
try {
|
||||
goeasy.on && goeasy.on('reconnected', () => {
|
||||
console.log('[KefuConnect] 重连成功')
|
||||
onGoeasyReconnected(proxy.$emitter, phone)
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KefuConnect] 连接异常:', e)
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[KefuConnect] 组件挂载,开始建立客服长连接')
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log('[KefuConnect] 组件卸载,但保持连接存活(可后续优化)')
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
398
src/utils/kefuChat.js
Normal file
398
src/utils/kefuChat.js
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="agent-container">
|
||||
<!-- 客服连接组件(无界面,负责建连) -->
|
||||
<KefuConnect @connected="onConnected" />
|
||||
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="agent-header">
|
||||
<div class="header-left">
|
||||
<el-tag :type="isOnline ? 'success' : 'info'" size="large">
|
||||
{{ isOnline ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
<el-tag :type="statusTagType" size="large">{{ statusText }}</el-tag>
|
||||
<el-button
|
||||
v-if="goeasyReady"
|
||||
:type="isOnline ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleOnline"
|
||||
@@ -17,6 +13,7 @@
|
||||
>
|
||||
{{ 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 }}条未读
|
||||
@@ -123,6 +120,7 @@
|
||||
:team-id="teamId"
|
||||
:customer-id="currentCustomerId"
|
||||
:customer-data="currentCustomerData"
|
||||
:agent-online="isOnline"
|
||||
@close="currentCustomerId = ''"
|
||||
/>
|
||||
</div>
|
||||
@@ -132,8 +130,18 @@
|
||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ChatDialog from '@/components/ChatDialog.vue'
|
||||
import KefuConnect from '@/components/KefuConnect.vue'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
KEFU_TEAM_ID,
|
||||
getCsteam,
|
||||
refreshKefuUnread,
|
||||
calcUnreadFromConversations,
|
||||
ensureCustomerAccepted,
|
||||
goOnlineManually,
|
||||
goOfflineManually,
|
||||
isGoeasyConnected,
|
||||
checkCsOnlineStatus
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -145,13 +153,14 @@ const availableRoles = [
|
||||
{ prefix: 'Zz', name: '组长' }
|
||||
]
|
||||
|
||||
const isOnline = ref(false)
|
||||
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 = 'support_team'
|
||||
const teamId = KEFU_TEAM_ID
|
||||
|
||||
const searchRole = ref('')
|
||||
const searchUid = ref('')
|
||||
@@ -164,9 +173,18 @@ const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726
|
||||
|
||||
const activeRole = ref('all')
|
||||
|
||||
function getCsteam() {
|
||||
if (!window.goeasy) return null
|
||||
return window.goeasy.im.csteam(teamId)
|
||||
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()
|
||||
}
|
||||
|
||||
// 过滤
|
||||
@@ -199,8 +217,8 @@ function bindConversationListeners() {
|
||||
conversationsUpdatedHandler = (data) => {
|
||||
const all = data.conversations || []
|
||||
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
|
||||
unreadTotal.value = data.unreadTotal || 0
|
||||
proxy.$emitter.emit('agent-unread', unreadTotal.value)
|
||||
unreadTotal.value = data.unreadTotal || calcUnreadFromConversations(activeList.value)
|
||||
refreshKefuUnread(proxy.$emitter)
|
||||
}
|
||||
window.goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
||||
|
||||
@@ -218,63 +236,52 @@ const onConnected = () => {
|
||||
fetchActiveConversations()
|
||||
}
|
||||
|
||||
// 上线
|
||||
const csteamOnline = () => {
|
||||
if (!window.goeasy) {
|
||||
console.warn('[Agent] 未建立连接,无法上线')
|
||||
const toggleOnline = () => {
|
||||
if (switching.value || !goeasyReady.value) {
|
||||
ElMessage.warning('消息服务尚未连接,请稍候…')
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) return
|
||||
csteam.online({
|
||||
teamData: { name: '阿龙电竞客服中心', avatar: '' },
|
||||
agentData: {
|
||||
name: localStorage.getItem('username') || 'KF',
|
||||
avatar: ''
|
||||
},
|
||||
onSuccess: () => {
|
||||
isOnline.value = true
|
||||
console.log('[Agent] 上线成功')
|
||||
ElMessage.success('已上线,开始接收会话')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[Agent] 上线失败:', error)
|
||||
ElMessage.error('上线失败:' + error.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下线
|
||||
const csteamOffline = () => {
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) return
|
||||
csteam.offline({
|
||||
onSuccess: () => {
|
||||
isOnline.value = false
|
||||
console.log('[Agent] 下线成功')
|
||||
ElMessage.success('已下线')
|
||||
pendingList.value = []
|
||||
},
|
||||
onFailed: (error) => {
|
||||
ElMessage.error('下线失败:' + error.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOnline = () => {
|
||||
if (switching.value) return
|
||||
switching.value = true
|
||||
if (isOnline.value) {
|
||||
csteamOffline()
|
||||
} else {
|
||||
csteamOnline()
|
||||
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 || '未知错误'))
|
||||
})
|
||||
})
|
||||
}
|
||||
setTimeout(() => { switching.value = false }, 1000)
|
||||
}
|
||||
|
||||
// 拉取待接入
|
||||
const fetchPendingConversations = () => {
|
||||
if (!window.goeasy) return
|
||||
window.goeasy.im.pendingConversations({
|
||||
@@ -295,27 +302,43 @@ const fetchActiveConversations = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const acceptConversation = (conv) => {
|
||||
const acceptAndOpenChat = async (conv, openDialog = true) => {
|
||||
if (activeList.value.length >= 20) {
|
||||
ElMessage.warning('最多同时接入20个会话')
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
csteam.accept({
|
||||
customer: { id: conv.id, data: conv.data || { name: conv.id, avatar: defaultAvatar } },
|
||||
onSuccess: () => {
|
||||
ElMessage.success('已接入')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
},
|
||||
onFailed: (error) => {
|
||||
ElMessage.error('接入失败:' + error.content)
|
||||
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 = getCsteam()
|
||||
const csteam = getCsteamLocal()
|
||||
csteam.end({
|
||||
id: conv.id,
|
||||
onSuccess: () => {
|
||||
@@ -328,34 +351,109 @@ const endConversation = (conv) => {
|
||||
})
|
||||
}
|
||||
|
||||
const openChat = (conv) => {
|
||||
currentCustomerId.value = conv.id || conv.userId
|
||||
currentCustomerData.value = conv.data || { name: currentCustomerId.value, avatar: defaultAvatar }
|
||||
dialogVisible.value = true
|
||||
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
|
||||
const fullId = searchRole.value + searchUid.value
|
||||
const exists = activeList.value.find(c => c.id === fullId)
|
||||
if (exists) {
|
||||
openChat(exists)
|
||||
if (!goeasyReady.value) {
|
||||
ElMessage.warning('消息服务连接中,请稍候…')
|
||||
return
|
||||
}
|
||||
currentCustomerId.value = fullId
|
||||
currentCustomerData.value = { name: fullId, avatar: defaultAvatar }
|
||||
dialogVisible.value = true
|
||||
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)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[Agent] 页面挂载')
|
||||
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(() => {
|
||||
if (conversationsUpdatedHandler) {
|
||||
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 (pendingUpdatedHandler) {
|
||||
if (window.goeasy && pendingUpdatedHandler) {
|
||||
window.goeasy.im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
|
||||
}
|
||||
})
|
||||
@@ -439,4 +537,8 @@ onUnmounted(() => {
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.online-tip {
|
||||
font-size: 12px;
|
||||
color: #8bc34a;
|
||||
}
|
||||
</style>
|
||||
@@ -145,6 +145,18 @@
|
||||
<el-descriptions-item label="打手分成" v-if="orderData.dashou_fencheng">
|
||||
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="管事打手接单分红"
|
||||
v-if="orderData.fadanpingtai === 2"
|
||||
>
|
||||
¥{{ formatPrice(orderData.guanshi_fencheng || 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="管事商家派单分红"
|
||||
v-if="orderData.fadanpingtai === 2"
|
||||
>
|
||||
¥{{ formatPrice(orderData.guanshi_shangjia_fencheng || 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单类型">
|
||||
{{ orderData.leixing || '--' }}
|
||||
</el-descriptions-item>
|
||||
@@ -527,6 +539,8 @@ const orderData = ref({
|
||||
is_cross: 0,
|
||||
jine: 0,
|
||||
dashou_fencheng: 0,
|
||||
guanshi_fencheng: 0,
|
||||
guanshi_shangjia_fencheng: 0,
|
||||
jiedan_dashou_id: '',
|
||||
dashou_liuyan: '',
|
||||
zhiding_id: '',
|
||||
|
||||
@@ -39,6 +39,46 @@
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="商家派单限额" name="shangjia_paifa">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="shop-hint"
|
||||
title="控制商家派单接口 /dingdan/sjpaifa 的单笔金额范围。修改后立即生效,小程序端以后台校验为准。"
|
||||
/>
|
||||
<el-form label-width="200px" class="shop-form" v-loading="sysLoading">
|
||||
<el-form-item label="单笔最低金额(元)">
|
||||
<el-input-number
|
||||
v-model="sysForm.shangjia_paifa_jiage_min"
|
||||
:min="0.01"
|
||||
:max="999999"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单笔最高金额(元)">
|
||||
<el-input-number
|
||||
v-model="sysForm.shangjia_paifa_jiage_max"
|
||||
:min="0.01"
|
||||
:max="999999"
|
||||
:precision="2"
|
||||
:step="100"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-text type="info">
|
||||
当前生效范围:{{ sysForm.shangjia_paifa_jiage_min }} ~ {{ sysForm.shangjia_paifa_jiage_max }} 元/单。
|
||||
若最低大于最高,保存时系统会自动对调。
|
||||
</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="sysSaving" @click="saveSysConfig">保存</el-button>
|
||||
<el-button @click="loadSysConfig">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="微信与支付配置" name="sys">
|
||||
<el-form :model="sysForm" label-width="160px" class="sys-form" v-loading="sysLoading">
|
||||
<el-form-item label="小程序 AppID">
|
||||
@@ -160,6 +200,8 @@ const sysForm = reactive({
|
||||
weixin_notify_url: '',
|
||||
notify_base_url: '',
|
||||
shangpin_shenhe_mode: 2,
|
||||
shangjia_paifa_jiage_min: 10,
|
||||
shangjia_paifa_jiage_max: 10000,
|
||||
})
|
||||
|
||||
const assetGroup = ref('paihangbang')
|
||||
@@ -196,6 +238,8 @@ const loadSysConfig = async () => {
|
||||
weixin_notify_url: res.data.weixin_notify_url || '',
|
||||
notify_base_url: res.data.notify_base_url || '',
|
||||
shangpin_shenhe_mode: res.data.shangpin_shenhe_mode ?? 2,
|
||||
shangjia_paifa_jiage_min: Number(res.data.shangjia_paifa_jiage_min ?? 10),
|
||||
shangjia_paifa_jiage_max: Number(res.data.shangjia_paifa_jiage_max ?? 10000),
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
@@ -219,7 +263,7 @@ const saveSysConfig = async () => {
|
||||
config: payload,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('微信配置已保存')
|
||||
ElMessage.success('配置已保存')
|
||||
loadSysConfig()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
|
||||
399
src/views/miniapp/ScriptConfig.vue
Normal file
399
src/views/miniapp/ScriptConfig.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<div class="script-config-manager">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</div>
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="header-bar">
|
||||
<h2>自动弹窗及话术配置</h2>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="scene-tabs">
|
||||
<el-tab-pane label="聊天发图确认" name="chat_image_confirm" />
|
||||
<el-tab-pane label="客服欢迎语" name="cs_welcome" />
|
||||
<el-tab-pane label="商家派单确认" name="merchant_dispatch_confirm" />
|
||||
<el-tab-pane label="客服自动回复" name="cs_auto_reply" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 单条话术:确认弹窗 / 欢迎语 -->
|
||||
<el-card v-if="activeTab !== 'cs_auto_reply'" class="single-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ currentSceneLabel }}</span>
|
||||
<el-tag :type="singleForm.is_active ? 'success' : 'info'" size="small">
|
||||
{{ singleForm.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="singleForm" label-width="120px" class="single-form">
|
||||
<el-form-item v-if="isConfirmScene" label="弹窗标题">
|
||||
<el-input v-model="singleForm.title" placeholder="弹窗标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正文内容">
|
||||
<el-input v-model="singleForm.content" type="textarea" :rows="6" placeholder="话术正文" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isConfirmScene" label="确认按钮">
|
||||
<el-input v-model="singleForm.confirm_text" placeholder="如:我已知晓,继续发送" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isConfirmScene" label="取消按钮">
|
||||
<el-input v-model="singleForm.cancel_text" placeholder="如:取消" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="singleForm.is_active" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="singleSubmitting" @click="saveSingle">
|
||||
保存{{ singleForm.id ? '修改' : '创建' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 自动回复规则列表 -->
|
||||
<div v-else class="auto-reply-panel">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAutoReplyDialog()">+ 添加自动回复规则</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="autoReplyList" border stripe>
|
||||
<el-table-column prop="priority" label="优先级" width="90" />
|
||||
<el-table-column label="触发关键词" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="kw in row.keywords" :key="kw" size="small" class="kw-tag">{{ kw }}</el-tag>
|
||||
<span v-if="!row.keywords || !row.keywords.length">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="match_mode" label="匹配模式" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.match_mode === 'exact' ? '精确' : '包含' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="回复内容" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openAutoReplyDialog(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="deleteAutoReply(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动回复编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="autoReplyDialogVisible"
|
||||
:title="autoReplyForm.id ? '编辑自动回复' : '添加自动回复'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="autoReplyForm" label-width="110px">
|
||||
<el-form-item label="触发关键词" required>
|
||||
<el-select
|
||||
v-model="autoReplyForm.keywords"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入后回车添加关键词"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配模式">
|
||||
<el-radio-group v-model="autoReplyForm.match_mode">
|
||||
<el-radio label="contains">包含匹配</el-radio>
|
||||
<el-radio label="exact">精确匹配</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="autoReplyForm.priority" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回复内容" required>
|
||||
<el-input v-model="autoReplyForm.content" type="textarea" :rows="5" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="autoReplyForm.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="autoReplyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="autoReplySubmitting" @click="submitAutoReply">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const activeTab = ref('chat_image_confirm')
|
||||
const allItems = ref([])
|
||||
const singleSubmitting = ref(false)
|
||||
|
||||
const SCENE_LABELS = {
|
||||
chat_image_confirm: '聊天发图确认',
|
||||
cs_welcome: '客服欢迎语',
|
||||
merchant_dispatch_confirm: '商家派单确认',
|
||||
cs_auto_reply: '客服自动回复',
|
||||
}
|
||||
|
||||
const singleForm = reactive({
|
||||
id: null,
|
||||
scene_key: 'chat_image_confirm',
|
||||
item_type: 'confirm_modal',
|
||||
title: '',
|
||||
content: '',
|
||||
confirm_text: '我知道了',
|
||||
cancel_text: '取消',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const currentSceneLabel = computed(() => SCENE_LABELS[activeTab.value] || activeTab.value)
|
||||
const isConfirmScene = computed(() => activeTab.value !== 'cs_welcome')
|
||||
const autoReplyList = computed(() =>
|
||||
allItems.value.filter((i) => i.scene_key === 'cs_auto_reply' && i.item_type === 'auto_reply')
|
||||
)
|
||||
|
||||
const autoReplyDialogVisible = ref(false)
|
||||
const autoReplySubmitting = ref(false)
|
||||
const autoReplyForm = reactive({
|
||||
id: null,
|
||||
keywords: [],
|
||||
match_mode: 'contains',
|
||||
priority: 0,
|
||||
content: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await request.post('/houtai/hthqhs', { username: username.value })
|
||||
if (res.code === 0) {
|
||||
allItems.value = res.data.items || []
|
||||
loadSingleForm()
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取数据失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('网络错误')
|
||||
}
|
||||
}
|
||||
|
||||
const getSingleItemType = (sceneKey) => {
|
||||
return sceneKey === 'cs_welcome' ? 'text_display' : 'confirm_modal'
|
||||
}
|
||||
|
||||
const loadSingleForm = () => {
|
||||
const sceneKey = activeTab.value
|
||||
if (sceneKey === 'cs_auto_reply') return
|
||||
|
||||
const item = allItems.value.find(
|
||||
(i) => i.scene_key === sceneKey && i.item_type === getSingleItemType(sceneKey)
|
||||
)
|
||||
|
||||
if (item) {
|
||||
Object.assign(singleForm, {
|
||||
id: item.id,
|
||||
scene_key: item.scene_key,
|
||||
item_type: item.item_type,
|
||||
title: item.title || '',
|
||||
content: item.content || '',
|
||||
confirm_text: item.confirm_text || '我知道了',
|
||||
cancel_text: item.cancel_text || '取消',
|
||||
is_active: item.is_active,
|
||||
})
|
||||
} else {
|
||||
Object.assign(singleForm, {
|
||||
id: null,
|
||||
scene_key: sceneKey,
|
||||
item_type: getSingleItemType(sceneKey),
|
||||
title: '',
|
||||
content: '',
|
||||
confirm_text: '我知道了',
|
||||
cancel_text: '取消',
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
loadSingleForm()
|
||||
})
|
||||
|
||||
const saveSingle = async () => {
|
||||
if (!singleForm.content || !singleForm.content.trim()) {
|
||||
ElMessage.warning('请填写正文内容')
|
||||
return
|
||||
}
|
||||
singleSubmitting.value = true
|
||||
try {
|
||||
const action = singleForm.id ? 'update' : 'create'
|
||||
const payload = {
|
||||
username: username.value,
|
||||
action,
|
||||
scene_key: singleForm.scene_key,
|
||||
item_type: singleForm.item_type,
|
||||
title: singleForm.title,
|
||||
content: singleForm.content.trim(),
|
||||
confirm_text: singleForm.confirm_text,
|
||||
cancel_text: singleForm.cancel_text,
|
||||
is_active: singleForm.is_active,
|
||||
}
|
||||
if (singleForm.id) payload.id = singleForm.id
|
||||
|
||||
const res = await request.post('/houtai/htxghs', payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
await fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
singleSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAutoReplyDialog = (row) => {
|
||||
if (row) {
|
||||
Object.assign(autoReplyForm, {
|
||||
id: row.id,
|
||||
keywords: [...(row.keywords || [])],
|
||||
match_mode: row.match_mode || 'contains',
|
||||
priority: row.priority || 0,
|
||||
content: row.content || '',
|
||||
is_active: row.is_active,
|
||||
})
|
||||
} else {
|
||||
Object.assign(autoReplyForm, {
|
||||
id: null,
|
||||
keywords: [],
|
||||
match_mode: 'contains',
|
||||
priority: 0,
|
||||
content: '',
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
autoReplyDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitAutoReply = async () => {
|
||||
if (!autoReplyForm.keywords.length) {
|
||||
ElMessage.warning('请至少添加一个触发关键词')
|
||||
return
|
||||
}
|
||||
if (!autoReplyForm.content || !autoReplyForm.content.trim()) {
|
||||
ElMessage.warning('请填写回复内容')
|
||||
return
|
||||
}
|
||||
autoReplySubmitting.value = true
|
||||
try {
|
||||
const action = autoReplyForm.id ? 'update' : 'create'
|
||||
const payload = {
|
||||
username: username.value,
|
||||
action,
|
||||
scene_key: 'cs_auto_reply',
|
||||
item_type: 'auto_reply',
|
||||
title: '',
|
||||
content: autoReplyForm.content.trim(),
|
||||
keywords: autoReplyForm.keywords,
|
||||
match_mode: autoReplyForm.match_mode,
|
||||
priority: autoReplyForm.priority,
|
||||
is_active: autoReplyForm.is_active,
|
||||
}
|
||||
if (autoReplyForm.id) payload.id = autoReplyForm.id
|
||||
|
||||
const res = await request.post('/houtai/htxghs', payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
autoReplyDialogVisible.value = false
|
||||
await fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
autoReplySubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAutoReply = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该自动回复规则?', '删除确认', { type: 'warning' })
|
||||
const res = await request.post('/houtai/htxghs', {
|
||||
username: username.value,
|
||||
action: 'delete',
|
||||
id: row.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
await fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-config-manager {
|
||||
padding: 16px;
|
||||
}
|
||||
.header-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.no-permission {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.no-permission-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.single-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.auto-reply-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.kw-tag {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -112,6 +112,18 @@
|
||||
<el-descriptions-item label="打手分成" v-if="orderData.dashou_fencheng">
|
||||
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="管事打手接单分红"
|
||||
v-if="orderData.fadanpingtai === 2"
|
||||
>
|
||||
¥{{ formatPrice(orderData.guanshi_fencheng || 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="管事商家派单分红"
|
||||
v-if="orderData.fadanpingtai === 2"
|
||||
>
|
||||
¥{{ formatPrice(orderData.guanshi_shangjia_fencheng || 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单类型">
|
||||
{{ orderData.leixing || '--' }}
|
||||
</el-descriptions-item>
|
||||
@@ -373,6 +385,8 @@ const orderData = ref({
|
||||
fadanpingtai: 0,
|
||||
jiage: 0,
|
||||
dashou_fencheng: 0,
|
||||
guanshi_fencheng: 0,
|
||||
guanshi_shangjia_fencheng: 0,
|
||||
jiedan_dashou_id: '',
|
||||
dashou_liuyan: '',
|
||||
zhiding_id: '',
|
||||
|
||||
@@ -192,17 +192,18 @@ const extraWithdrawItems = [
|
||||
{ type: 6, name: '商家余额提现' }
|
||||
]
|
||||
|
||||
// 订单分红费率(Lilubiao fadanpingtai:1平台订单 / 3商家订单 / 12押金管事 / 13管事订单)
|
||||
// 订单分红费率(Lilubiao fadanpingtai:1平台订单 / 3商家订单 / 12押金管事 / 13管事打手接单 / 15商家派单管事)
|
||||
const orderCommissionItems = [
|
||||
{ type: 1, name: '平台订单打手分红' },
|
||||
{ type: 3, name: '商家订单打手分红' },
|
||||
{ type: 12, name: '押金管事分红' },
|
||||
{ type: 13, name: '管事订单分红' }
|
||||
{ type: 13, name: '管事打手接单分红' },
|
||||
{ type: 15, name: '商家派单管事分红' }
|
||||
]
|
||||
|
||||
const DEFAULT_RATES = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }
|
||||
const DEFAULT_TOTAL_LIMITS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }
|
||||
const DEFAULT_ORDER_RATES = { 1: 0, 3: 0, 12: 0, 13: 0 }
|
||||
const DEFAULT_ORDER_RATES = { 1: 0, 3: 0, 12: 0, 13: 0, 15: 0 }
|
||||
|
||||
// 🆕 提现模式
|
||||
const withdrawMode = ref(2) // 默认手动提现
|
||||
@@ -252,7 +253,8 @@ const mergeOrderRates = (source = {}) => ({
|
||||
1: source[1] ?? source['1'] ?? DEFAULT_ORDER_RATES[1],
|
||||
3: source[3] ?? source['3'] ?? DEFAULT_ORDER_RATES[3],
|
||||
12: source[12] ?? source['12'] ?? DEFAULT_ORDER_RATES[12],
|
||||
13: source[13] ?? source['13'] ?? DEFAULT_ORDER_RATES[13]
|
||||
13: source[13] ?? source['13'] ?? DEFAULT_ORDER_RATES[13],
|
||||
15: source[15] ?? source['15'] ?? DEFAULT_ORDER_RATES[15],
|
||||
})
|
||||
|
||||
const mergeTotalLimits = (source = {}) => ({
|
||||
@@ -495,7 +497,7 @@ const hasSettingsChanged = () => {
|
||||
for (const key of [1, 2, 3, 4, 5, 6]) {
|
||||
if (editData.rates[key] !== originalData.rates[key]) return true
|
||||
}
|
||||
for (const key of [1, 3, 12, 13]) {
|
||||
for (const key of orderCommissionItems.map((item) => item.type)) {
|
||||
if (editData.orderRates[key] !== originalData.orderRates[key]) return true
|
||||
}
|
||||
for (const key of [1, 2, 3]) {
|
||||
|
||||
Reference in New Issue
Block a user