fix: 登录页校验JWT有效性;同步后台多项功能更新
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
width="700px"
|
width="700px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@open="onDialogOpen"
|
@opened="onDialogOpened"
|
||||||
@close="onDialogClose"
|
@close="onDialogClose"
|
||||||
>
|
>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<el-input v-model="inputText" placeholder="请输入消息..." @keyup.enter="sendText" size="small">
|
<el-input v-model="inputText" placeholder="请输入消息..." @keyup.enter="sendText" size="small">
|
||||||
<template #append>
|
<template #append>
|
||||||
<el-button @click="sendText" :disabled="!inputText.trim()">发送</el-button>
|
<el-button @click="sendText" :disabled="!inputText.trim() || !agentOnline">发送</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,16 +63,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onUnmounted, getCurrentInstance } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import GoEasy from 'goeasy'
|
import GoEasy from 'goeasy'
|
||||||
|
import {
|
||||||
|
isCsMessageForCustomer,
|
||||||
|
buildCsTextMessage,
|
||||||
|
ensureCustomerAccepted
|
||||||
|
} from '@/utils/kefuChat'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
teamId: String,
|
teamId: String,
|
||||||
customerId: String,
|
customerId: String,
|
||||||
customerData: Object
|
customerData: Object,
|
||||||
|
agentOnline: { type: Boolean, default: true }
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:visible'])
|
const emit = defineEmits(['update:visible'])
|
||||||
|
|
||||||
@@ -97,8 +105,38 @@ const isSelf = (msg) => msg.senderId === agentId.value
|
|||||||
|
|
||||||
let csteam = null
|
let csteam = null
|
||||||
let unlisten = 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 = () => {
|
const startListenCustomer = () => {
|
||||||
if (!window.goeasy || !props.teamId || !props.customerId) return
|
if (!window.goeasy || !props.teamId || !props.customerId) return
|
||||||
csteam = window.goeasy.im.csteam(props.teamId)
|
csteam = window.goeasy.im.csteam(props.teamId)
|
||||||
@@ -110,24 +148,7 @@ const startListenCustomer = () => {
|
|||||||
csteam.listenCustomer({
|
csteam.listenCustomer({
|
||||||
id: props.customerId,
|
id: props.customerId,
|
||||||
onNewMessage: (message) => {
|
onNewMessage: (message) => {
|
||||||
console.log('[ChatDialog] 客户新消息:', message)
|
handleIncomingMessage(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()
|
|
||||||
},
|
},
|
||||||
onStatusUpdated: (status) => {
|
onStatusUpdated: (status) => {
|
||||||
console.log('[ChatDialog] 客户状态更新:', status)
|
console.log('[ChatDialog] 客户状态更新:', status)
|
||||||
@@ -190,23 +211,28 @@ const refreshHistory = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----- 发送消息 -----
|
// ----- 发送消息 -----
|
||||||
const sendText = () => {
|
const sendText = async () => {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
if (!window.goeasy) return ElMessage.error('聊天服务未连接')
|
if (!window.goeasy) return ElMessage.error('聊天服务未连接')
|
||||||
|
if (!props.agentOnline && !window.__kefuCsOnline) {
|
||||||
const message = window.goeasy.im.createTextMessage({
|
ElMessage.warning('客服正在连接,请稍候…')
|
||||||
text: text,
|
return
|
||||||
to: {
|
}
|
||||||
type: 'cs',
|
if (!sessionReady) {
|
||||||
id: props.customerId,
|
try {
|
||||||
data: props.customerData || { name: props.customerId, avatar: '' }
|
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 tempId = 'local-' + Date.now()
|
||||||
const tempMsg = {
|
const tempMsg = {
|
||||||
@@ -271,23 +297,36 @@ const markRead = () => {
|
|||||||
}
|
}
|
||||||
const previewImage = (url) => window.open(url)
|
const previewImage = (url) => window.open(url)
|
||||||
|
|
||||||
// ----- 弹窗生命周期 -----
|
// ----- 弹窗生命周期(@opened 确保 DOM/props 就绪) -----
|
||||||
const onDialogOpen = () => {
|
const onDialogOpened = async () => {
|
||||||
console.log('[ChatDialog] 弹窗打开')
|
console.log('[ChatDialog] 弹窗已打开, customerId=', props.customerId)
|
||||||
if (!agentId.value) agentId.value = window.__kefuAgentId || 'KF' + localStorage.getItem('username')
|
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()
|
startListenCustomer()
|
||||||
loadHistory()
|
loadHistory()
|
||||||
}
|
}
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
// 关闭弹窗时不需要主动取消 listenCustomer,因为我们会重新 listen
|
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||||
// 但可以清理 unlisten 引用
|
|
||||||
unlisten = null
|
unlisten = null
|
||||||
|
sessionReady = false
|
||||||
messages.value = []
|
messages.value = []
|
||||||
lastTimestamp.value = null
|
lastTimestamp.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||||
unlisten = null
|
unlisten = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,91 +3,101 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
import { onMounted, getCurrentInstance } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import GoEasy from 'goeasy'
|
import GoEasy from 'goeasy'
|
||||||
|
import {
|
||||||
|
afterGoeasyConnected,
|
||||||
|
resumeKefuSession,
|
||||||
|
onGoeasyReconnected,
|
||||||
|
isGoeasyConnected
|
||||||
|
} from '@/utils/kefuChat'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
const emit = defineEmits(['connected'])
|
const emit = defineEmits(['connected'])
|
||||||
|
|
||||||
// 每次进入页面都重新获取权限并建立连接
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
console.log('[KefuConnect] 开始获取客服权限...')
|
|
||||||
const phone = localStorage.getItem('username')
|
const phone = localStorage.getItem('username')
|
||||||
if (!phone) {
|
if (!phone) return
|
||||||
console.error('[KefuConnect] 未找到手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await request.post('/houtai/kfhqltqx', { phone })
|
const res = await request.post('/houtai/kfhqltqx', { phone })
|
||||||
if (res.code !== 0) {
|
if (res.code !== 0) return
|
||||||
console.warn('[KefuConnect] 接口返回非0:', res)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { permissions, goeasy_appkey } = res.data
|
const { permissions, goeasy_appkey } = res.data
|
||||||
console.log('[KefuConnect] 权限码:', permissions)
|
if (!permissions?.length || !goeasy_appkey) return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentId = 'KF' + phone
|
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({
|
const goeasy = GoEasy.getInstance({
|
||||||
host: 'hangzhou.goeasy.io', // 根据实际区域修改
|
host: 'hangzhou.goeasy.io',
|
||||||
appkey: goeasy_appkey,
|
appkey: goeasy_appkey,
|
||||||
modules: ['im']
|
modules: ['im']
|
||||||
})
|
})
|
||||||
console.log('[KefuConnect] GoEasy 实例创建成功,准备连接...')
|
|
||||||
|
|
||||||
goeasy.connect({
|
goeasy.connect({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
data: { name: phone, avatar: '' },
|
data: { name: phone, avatar: '' },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('[KefuConnect] ✅ 连接成功!')
|
|
||||||
window.goeasy = goeasy
|
window.goeasy = goeasy
|
||||||
window.__kefuAgentId = agentId
|
window.__kefuAgentId = agentId
|
||||||
// 通知父组件(Agent.vue)可以进行上线操作
|
proxy.$emitter.emit('kefu-goeasy-ready', true)
|
||||||
|
afterGoeasyConnected(proxy.$emitter, phone)
|
||||||
emit('connected')
|
emit('connected')
|
||||||
proxy.$emitter.emit('goeasy-connected')
|
proxy.$emitter.emit('goeasy-connected')
|
||||||
},
|
},
|
||||||
onFailed: (error) => {
|
onFailed: (error) => {
|
||||||
console.error('[KefuConnect] ❌ 连接失败:', error)
|
console.error('[KefuConnect] 连接失败:', error)
|
||||||
ElMessage.error('消息服务连接失败,请重试')
|
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||||
|
ElMessage.error('消息服务连接失败,请刷新重试')
|
||||||
},
|
},
|
||||||
onProgress: (attempts) => {
|
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) {
|
} catch (e) {
|
||||||
console.error('[KefuConnect] 连接异常:', e)
|
console.error('[KefuConnect] 连接异常:', e)
|
||||||
|
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('[KefuConnect] 组件挂载,开始建立客服长连接')
|
|
||||||
connect()
|
connect()
|
||||||
})
|
})
|
||||||
|
</script>
|
||||||
onUnmounted(() => {
|
|
||||||
console.log('[KefuConnect] 组件卸载,但保持连接存活(可后续优化)')
|
|
||||||
})
|
|
||||||
</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: ''
|
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(() => {
|
onMounted(() => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (isStoredTokenUsable(token)) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('username')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="agent-container">
|
<div class="agent-container">
|
||||||
<!-- 客服连接组件(无界面,负责建连) -->
|
|
||||||
<KefuConnect @connected="onConnected" />
|
|
||||||
|
|
||||||
<!-- 顶部操作栏 -->
|
<!-- 顶部操作栏 -->
|
||||||
<div class="agent-header">
|
<div class="agent-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-tag :type="isOnline ? 'success' : 'info'" size="large">
|
<el-tag :type="statusTagType" size="large">{{ statusText }}</el-tag>
|
||||||
{{ isOnline ? '在线' : '离线' }}
|
|
||||||
</el-tag>
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="goeasyReady"
|
||||||
:type="isOnline ? 'danger' : 'primary'"
|
:type="isOnline ? 'danger' : 'primary'"
|
||||||
size="small"
|
size="small"
|
||||||
@click="toggleOnline"
|
@click="toggleOnline"
|
||||||
@@ -17,6 +13,7 @@
|
|||||||
>
|
>
|
||||||
{{ isOnline ? '下线' : '上线' }}
|
{{ isOnline ? '下线' : '上线' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<span class="online-tip" v-if="goeasyReady && isOnline">切换页面不会下线</span>
|
||||||
<span class="unread-tip" v-if="unreadTotal">
|
<span class="unread-tip" v-if="unreadTotal">
|
||||||
<i class="el-icon-chat-dot-round" style="color: red; margin-right: 4px;"></i>
|
<i class="el-icon-chat-dot-round" style="color: red; margin-right: 4px;"></i>
|
||||||
{{ unreadTotal }}条未读
|
{{ unreadTotal }}条未读
|
||||||
@@ -123,6 +120,7 @@
|
|||||||
:team-id="teamId"
|
:team-id="teamId"
|
||||||
:customer-id="currentCustomerId"
|
:customer-id="currentCustomerId"
|
||||||
:customer-data="currentCustomerData"
|
:customer-data="currentCustomerData"
|
||||||
|
:agent-online="isOnline"
|
||||||
@close="currentCustomerId = ''"
|
@close="currentCustomerId = ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,8 +130,18 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import ChatDialog from '@/components/ChatDialog.vue'
|
import ChatDialog from '@/components/ChatDialog.vue'
|
||||||
import KefuConnect from '@/components/KefuConnect.vue'
|
|
||||||
import GoEasy from 'goeasy'
|
import GoEasy from 'goeasy'
|
||||||
|
import {
|
||||||
|
KEFU_TEAM_ID,
|
||||||
|
getCsteam,
|
||||||
|
refreshKefuUnread,
|
||||||
|
calcUnreadFromConversations,
|
||||||
|
ensureCustomerAccepted,
|
||||||
|
goOnlineManually,
|
||||||
|
goOfflineManually,
|
||||||
|
isGoeasyConnected,
|
||||||
|
checkCsOnlineStatus
|
||||||
|
} from '@/utils/kefuChat'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -145,13 +153,14 @@ const availableRoles = [
|
|||||||
{ prefix: 'Zz', name: '组长' }
|
{ prefix: 'Zz', name: '组长' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const isOnline = ref(false)
|
const goeasyReady = ref(isGoeasyConnected())
|
||||||
|
const isOnline = ref(!!window.__kefuCsOnline)
|
||||||
const switching = ref(false)
|
const switching = ref(false)
|
||||||
const tabActive = ref('pending')
|
const tabActive = ref('pending')
|
||||||
const pendingList = ref([])
|
const pendingList = ref([])
|
||||||
const activeList = ref([])
|
const activeList = ref([])
|
||||||
const unreadTotal = ref(0)
|
const unreadTotal = ref(0)
|
||||||
const teamId = 'support_team'
|
const teamId = KEFU_TEAM_ID
|
||||||
|
|
||||||
const searchRole = ref('')
|
const searchRole = ref('')
|
||||||
const searchUid = ref('')
|
const searchUid = ref('')
|
||||||
@@ -164,9 +173,18 @@ const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726
|
|||||||
|
|
||||||
const activeRole = ref('all')
|
const activeRole = ref('all')
|
||||||
|
|
||||||
function getCsteam() {
|
const statusText = computed(() => {
|
||||||
if (!window.goeasy) return null
|
if (!goeasyReady.value) return '消息连接中…'
|
||||||
return window.goeasy.im.csteam(teamId)
|
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) => {
|
conversationsUpdatedHandler = (data) => {
|
||||||
const all = data.conversations || []
|
const all = data.conversations || []
|
||||||
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
|
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
|
||||||
unreadTotal.value = data.unreadTotal || 0
|
unreadTotal.value = data.unreadTotal || calcUnreadFromConversations(activeList.value)
|
||||||
proxy.$emitter.emit('agent-unread', unreadTotal.value)
|
refreshKefuUnread(proxy.$emitter)
|
||||||
}
|
}
|
||||||
window.goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
window.goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
||||||
|
|
||||||
@@ -218,63 +236,52 @@ const onConnected = () => {
|
|||||||
fetchActiveConversations()
|
fetchActiveConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上线
|
const toggleOnline = () => {
|
||||||
const csteamOnline = () => {
|
if (switching.value || !goeasyReady.value) {
|
||||||
if (!window.goeasy) {
|
ElMessage.warning('消息服务尚未连接,请稍候…')
|
||||||
console.warn('[Agent] 未建立连接,无法上线')
|
|
||||||
return
|
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
|
switching.value = true
|
||||||
if (isOnline.value) {
|
const phone = localStorage.getItem('username')
|
||||||
csteamOffline()
|
const stopSpin = () => { switching.value = false }
|
||||||
} else {
|
const safetyTimer = setTimeout(stopSpin, 15000)
|
||||||
csteamOnline()
|
|
||||||
|
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 = () => {
|
const fetchPendingConversations = () => {
|
||||||
if (!window.goeasy) return
|
if (!window.goeasy) return
|
||||||
window.goeasy.im.pendingConversations({
|
window.goeasy.im.pendingConversations({
|
||||||
@@ -295,27 +302,43 @@ const fetchActiveConversations = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptConversation = (conv) => {
|
const acceptAndOpenChat = async (conv, openDialog = true) => {
|
||||||
if (activeList.value.length >= 20) {
|
if (activeList.value.length >= 20) {
|
||||||
ElMessage.warning('最多同时接入20个会话')
|
ElMessage.warning('最多同时接入20个会话')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const csteam = getCsteam()
|
if (!goeasyReady.value) {
|
||||||
csteam.accept({
|
ElMessage.warning('消息服务连接中,请稍候…')
|
||||||
customer: { id: conv.id, data: conv.data || { name: conv.id, avatar: defaultAvatar } },
|
return
|
||||||
onSuccess: () => {
|
}
|
||||||
ElMessage.success('已接入')
|
if (!isOnline.value) {
|
||||||
fetchPendingConversations()
|
ElMessage.warning('请先上线后再接入会话')
|
||||||
fetchActiveConversations()
|
return
|
||||||
},
|
}
|
||||||
onFailed: (error) => {
|
const customerId = conv.id || conv.userId
|
||||||
ElMessage.error('接入失败:' + error.content)
|
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 endConversation = (conv) => {
|
||||||
const csteam = getCsteam()
|
const csteam = getCsteamLocal()
|
||||||
csteam.end({
|
csteam.end({
|
||||||
id: conv.id,
|
id: conv.id,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -328,34 +351,109 @@ const endConversation = (conv) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openChat = (conv) => {
|
const openChat = async (conv) => {
|
||||||
currentCustomerId.value = conv.id || conv.userId
|
const customerId = conv.id || conv.userId
|
||||||
currentCustomerData.value = conv.data || { name: currentCustomerId.value, avatar: defaultAvatar }
|
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
|
||||||
dialogVisible.value = true
|
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 = () => {
|
const startNewChat = () => {
|
||||||
if (!searchRole.value || !searchUid.value) return
|
if (!searchRole.value || !searchUid.value) return
|
||||||
const fullId = searchRole.value + searchUid.value
|
if (!goeasyReady.value) {
|
||||||
const exists = activeList.value.find(c => c.id === fullId)
|
ElMessage.warning('消息服务连接中,请稍候…')
|
||||||
if (exists) {
|
|
||||||
openChat(exists)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentCustomerId.value = fullId
|
if (!isOnline.value) {
|
||||||
currentCustomerData.value = { name: fullId, avatar: defaultAvatar }
|
ElMessage.warning('请先上线后再接入会话')
|
||||||
dialogVisible.value = true
|
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(() => {
|
const onAgentUnread = (count) => {
|
||||||
console.log('[Agent] 页面挂载')
|
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(() => {
|
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)
|
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)
|
window.goeasy.im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -439,4 +537,8 @@ onUnmounted(() => {
|
|||||||
color: #888;
|
color: #888;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.online-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8bc34a;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -145,6 +145,18 @@
|
|||||||
<el-descriptions-item label="打手分成" v-if="orderData.dashou_fencheng">
|
<el-descriptions-item label="打手分成" v-if="orderData.dashou_fencheng">
|
||||||
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
||||||
</el-descriptions-item>
|
</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="订单类型">
|
<el-descriptions-item label="订单类型">
|
||||||
{{ orderData.leixing || '--' }}
|
{{ orderData.leixing || '--' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -527,6 +539,8 @@ const orderData = ref({
|
|||||||
is_cross: 0,
|
is_cross: 0,
|
||||||
jine: 0,
|
jine: 0,
|
||||||
dashou_fencheng: 0,
|
dashou_fencheng: 0,
|
||||||
|
guanshi_fencheng: 0,
|
||||||
|
guanshi_shangjia_fencheng: 0,
|
||||||
jiedan_dashou_id: '',
|
jiedan_dashou_id: '',
|
||||||
dashou_liuyan: '',
|
dashou_liuyan: '',
|
||||||
zhiding_id: '',
|
zhiding_id: '',
|
||||||
|
|||||||
@@ -39,6 +39,46 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</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-tab-pane label="微信与支付配置" name="sys">
|
||||||
<el-form :model="sysForm" label-width="160px" class="sys-form" v-loading="sysLoading">
|
<el-form :model="sysForm" label-width="160px" class="sys-form" v-loading="sysLoading">
|
||||||
<el-form-item label="小程序 AppID">
|
<el-form-item label="小程序 AppID">
|
||||||
@@ -160,6 +200,8 @@ const sysForm = reactive({
|
|||||||
weixin_notify_url: '',
|
weixin_notify_url: '',
|
||||||
notify_base_url: '',
|
notify_base_url: '',
|
||||||
shangpin_shenhe_mode: 2,
|
shangpin_shenhe_mode: 2,
|
||||||
|
shangjia_paifa_jiage_min: 10,
|
||||||
|
shangjia_paifa_jiage_max: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const assetGroup = ref('paihangbang')
|
const assetGroup = ref('paihangbang')
|
||||||
@@ -196,6 +238,8 @@ const loadSysConfig = async () => {
|
|||||||
weixin_notify_url: res.data.weixin_notify_url || '',
|
weixin_notify_url: res.data.weixin_notify_url || '',
|
||||||
notify_base_url: res.data.notify_base_url || '',
|
notify_base_url: res.data.notify_base_url || '',
|
||||||
shangpin_shenhe_mode: res.data.shangpin_shenhe_mode ?? 2,
|
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 {
|
} else {
|
||||||
ElMessage.error(res.msg || '加载失败')
|
ElMessage.error(res.msg || '加载失败')
|
||||||
@@ -219,7 +263,7 @@ const saveSysConfig = async () => {
|
|||||||
config: payload,
|
config: payload,
|
||||||
})
|
})
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
ElMessage.success('微信配置已保存')
|
ElMessage.success('配置已保存')
|
||||||
loadSysConfig()
|
loadSysConfig()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.msg || '保存失败')
|
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">
|
<el-descriptions-item label="打手分成" v-if="orderData.dashou_fencheng">
|
||||||
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
¥{{ formatPrice(orderData.dashou_fencheng) }}
|
||||||
</el-descriptions-item>
|
</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="订单类型">
|
<el-descriptions-item label="订单类型">
|
||||||
{{ orderData.leixing || '--' }}
|
{{ orderData.leixing || '--' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -373,6 +385,8 @@ const orderData = ref({
|
|||||||
fadanpingtai: 0,
|
fadanpingtai: 0,
|
||||||
jiage: 0,
|
jiage: 0,
|
||||||
dashou_fencheng: 0,
|
dashou_fencheng: 0,
|
||||||
|
guanshi_fencheng: 0,
|
||||||
|
guanshi_shangjia_fencheng: 0,
|
||||||
jiedan_dashou_id: '',
|
jiedan_dashou_id: '',
|
||||||
dashou_liuyan: '',
|
dashou_liuyan: '',
|
||||||
zhiding_id: '',
|
zhiding_id: '',
|
||||||
|
|||||||
@@ -192,17 +192,18 @@ const extraWithdrawItems = [
|
|||||||
{ type: 6, name: '商家余额提现' }
|
{ type: 6, name: '商家余额提现' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 订单分红费率(Lilubiao fadanpingtai:1平台订单 / 3商家订单 / 12押金管事 / 13管事订单)
|
// 订单分红费率(Lilubiao fadanpingtai:1平台订单 / 3商家订单 / 12押金管事 / 13管事打手接单 / 15商家派单管事)
|
||||||
const orderCommissionItems = [
|
const orderCommissionItems = [
|
||||||
{ type: 1, name: '平台订单打手分红' },
|
{ type: 1, name: '平台订单打手分红' },
|
||||||
{ type: 3, name: '商家订单打手分红' },
|
{ type: 3, name: '商家订单打手分红' },
|
||||||
{ type: 12, 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_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_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) // 默认手动提现
|
const withdrawMode = ref(2) // 默认手动提现
|
||||||
@@ -252,7 +253,8 @@ const mergeOrderRates = (source = {}) => ({
|
|||||||
1: source[1] ?? source['1'] ?? DEFAULT_ORDER_RATES[1],
|
1: source[1] ?? source['1'] ?? DEFAULT_ORDER_RATES[1],
|
||||||
3: source[3] ?? source['3'] ?? DEFAULT_ORDER_RATES[3],
|
3: source[3] ?? source['3'] ?? DEFAULT_ORDER_RATES[3],
|
||||||
12: source[12] ?? source['12'] ?? DEFAULT_ORDER_RATES[12],
|
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 = {}) => ({
|
const mergeTotalLimits = (source = {}) => ({
|
||||||
@@ -495,7 +497,7 @@ const hasSettingsChanged = () => {
|
|||||||
for (const key of [1, 2, 3, 4, 5, 6]) {
|
for (const key of [1, 2, 3, 4, 5, 6]) {
|
||||||
if (editData.rates[key] !== originalData.rates[key]) return true
|
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
|
if (editData.orderRates[key] !== originalData.orderRates[key]) return true
|
||||||
}
|
}
|
||||||
for (const key of [1, 2, 3]) {
|
for (const key of [1, 2, 3]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user