chore: 保留后台当前版本(工单菜单、支付通道配置、聊天相关改动)
UFO 适配开工前的检查点,便于回滚。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,7 +10,10 @@
|
||||
>
|
||||
<div class="chat-container">
|
||||
<div class="chat-toolbar">
|
||||
<span class="toolbar-title">客服对话</span>
|
||||
<span class="toolbar-title">
|
||||
客服对话
|
||||
<span class="target-id" v-if="safeCustomerId">→ {{ safeCustomerId }}</span>
|
||||
</span>
|
||||
<el-button size="small" text @click="refreshHistory" :loading="loadingHistory">
|
||||
<el-icon><Refresh /></el-icon> 刷新历史
|
||||
</el-button>
|
||||
@@ -33,17 +36,22 @@
|
||||
<el-image :src="msg.payload?.url" fit="contain" style="max-width: 200px;" />
|
||||
</div>
|
||||
<div v-else-if="msg.type === 'order'" class="msg-order">
|
||||
<div>📋 订单号:{{ msg.payload?.dingdan_id }}</div>
|
||||
<div>订单号:{{ msg.payload?.dingdan_id }}</div>
|
||||
<div>金额:¥{{ msg.payload?.jine }}</div>
|
||||
</div>
|
||||
<div v-else class="msg-text">[{{ msg.type || '未知' }}] {{ msg.payload?.text || '' }}</div>
|
||||
</div>
|
||||
<el-avatar v-if="isSelf(msg)" :size="32" :src="agentAvatar" class="msg-avatar" />
|
||||
</div>
|
||||
<div v-if="isSelf(msg) && msg.status === 'failed'" class="send-failed">
|
||||
<el-tooltip :content="msg.failReason || '发送失败'">
|
||||
<div
|
||||
v-if="isSelf(msg) && msg.status === 'failed'"
|
||||
class="send-failed"
|
||||
@click="resendFailed(msg)"
|
||||
>
|
||||
<el-tooltip :content="(msg.failReason || '发送失败') + '(点击重发)'">
|
||||
<el-icon color="#f56c6c"><WarningFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<span class="resend-tip">点击重发</span>
|
||||
</div>
|
||||
<div v-if="isSelf(msg) && msg.status === 'sending'" class="send-sending">
|
||||
<span style="font-size: 12px; color: #999;">发送中...</span>
|
||||
@@ -52,9 +60,21 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
:disabled="sending"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="sendText" :disabled="!inputText.trim() || !agentOnline">发送</el-button>
|
||||
<el-button
|
||||
@click="sendText"
|
||||
:disabled="!inputText.trim() || !canSend || sending"
|
||||
:loading="sending"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
@@ -63,14 +83,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { ref, computed, nextTick, onUnmounted, getCurrentInstance, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
isCsMessageForCustomer,
|
||||
buildCsTextMessage,
|
||||
ensureCustomerAccepted
|
||||
ensureCustomerAccepted,
|
||||
sendCsTextReliable,
|
||||
normalizeCustomerImId,
|
||||
isGoeasyConnected
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
@@ -89,11 +111,20 @@ const dialogVisible = computed({
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const chatTitle = computed(() => `与 ${props.customerData?.name || props.customerId} 聊天中`)
|
||||
const safeCustomerId = computed(() => {
|
||||
const n = normalizeCustomerImId(props.customerId)
|
||||
return n.ok ? n.id : (props.customerId || '')
|
||||
})
|
||||
|
||||
const chatTitle = computed(() => {
|
||||
const name = props.customerData?.name || safeCustomerId.value
|
||||
return `与 ${name} 聊天中`
|
||||
})
|
||||
|
||||
const messages = ref([])
|
||||
const inputText = ref('')
|
||||
const loadingHistory = ref(false)
|
||||
const sending = ref(false)
|
||||
const lastTimestamp = ref(null)
|
||||
const msgListRef = ref(null)
|
||||
|
||||
@@ -101,11 +132,16 @@ const agentId = ref(window.__kefuAgentId || 'KF' + localStorage.getItem('usernam
|
||||
const agentAvatar = ''
|
||||
const customerAvatar = computed(() => props.customerData?.avatar || '')
|
||||
|
||||
const canSend = computed(() => {
|
||||
return !!(props.agentOnline || window.__kefuCsOnline) && isGoeasyConnected() && !!safeCustomerId.value
|
||||
})
|
||||
|
||||
const isSelf = (msg) => msg.senderId === agentId.value
|
||||
|
||||
let csteam = null
|
||||
let unlisten = null
|
||||
let listeningCustomerId = ''
|
||||
let sessionReady = false
|
||||
let sessionCustomerId = ''
|
||||
|
||||
const pushMessage = (message) => {
|
||||
if (!message?.messageId) return
|
||||
@@ -117,10 +153,11 @@ const pushMessage = (message) => {
|
||||
}
|
||||
|
||||
const handleIncomingMessage = (message) => {
|
||||
if (!isCsMessageForCustomer(message, props.customerId, props.teamId, agentId.value)) return
|
||||
console.log('[ChatDialog] 收到实时消息:', message)
|
||||
const target = safeCustomerId.value
|
||||
if (!isCsMessageForCustomer(message, target, props.teamId, agentId.value)) return
|
||||
|
||||
const tempMsg = messages.value.find(
|
||||
m => m._tempId && m.payload?.text === message.payload?.text && m.senderId === agentId.value
|
||||
m => m._tempId && m.payload?.text === message.payload?.text && m.senderId === agentId.value && m.status === 'sending'
|
||||
)
|
||||
if (tempMsg && message.senderId === agentId.value) {
|
||||
tempMsg.messageId = message.messageId
|
||||
@@ -139,46 +176,39 @@ const onGlobalCsMessage = (message) => {
|
||||
}
|
||||
|
||||
const startListenCustomer = () => {
|
||||
if (!window.goeasy || !props.teamId || !props.customerId) return
|
||||
const target = safeCustomerId.value
|
||||
if (!window.goeasy || !props.teamId || !target) return
|
||||
csteam = window.goeasy.im.csteam(props.teamId)
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
listeningCustomerId = target
|
||||
csteam.listenCustomer({
|
||||
id: props.customerId,
|
||||
id: target,
|
||||
onNewMessage: (message) => {
|
||||
if (listeningCustomerId !== target) return
|
||||
handleIncomingMessage(message)
|
||||
},
|
||||
onStatusUpdated: (status) => {
|
||||
console.log('[ChatDialog] 客户状态更新:', status)
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('[ChatDialog] 监听客户成功')
|
||||
},
|
||||
onStatusUpdated: () => {},
|
||||
onSuccess: () => {},
|
||||
onFailed: (error) => {
|
||||
console.error('[ChatDialog] 监听客户失败:', error)
|
||||
}
|
||||
})
|
||||
unlisten = () => {}
|
||||
}
|
||||
|
||||
const loadHistory = () => {
|
||||
if (!props.teamId || !props.customerId) return
|
||||
const csteam = window.goeasy?.im?.csteam(props.teamId)
|
||||
if (!csteam) {
|
||||
const target = safeCustomerId.value
|
||||
if (!props.teamId || !target) return
|
||||
const team = window.goeasy?.im?.csteam(props.teamId)
|
||||
if (!team) {
|
||||
ElMessage.error('客服连接异常,请刷新页面')
|
||||
return
|
||||
}
|
||||
loadingHistory.value = true
|
||||
console.log(`[ChatDialog] 加载历史: customerId=${props.customerId}`)
|
||||
csteam.history({
|
||||
id: props.customerId,
|
||||
team.history({
|
||||
id: target,
|
||||
type: GoEasy.IM_SCENE.CS,
|
||||
lastTimestamp: lastTimestamp.value,
|
||||
limit: 20,
|
||||
onSuccess: (result) => {
|
||||
console.log('[ChatDialog] 历史加载成功,数量:', result.content?.length)
|
||||
const list = result.content || []
|
||||
list.sort((a, b) => a.timestamp - b.timestamp)
|
||||
list.forEach((m, idx) => {
|
||||
@@ -193,9 +223,8 @@ const loadHistory = () => {
|
||||
})
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[ChatDialog] 历史加载失败:', error)
|
||||
loadingHistory.value = false
|
||||
ElMessage.error('加载历史消息失败:' + error.content)
|
||||
ElMessage.error('加载历史消息失败:' + (error?.content || '未知错误'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -206,30 +235,68 @@ const refreshHistory = () => {
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
const doSendText = async (text, tempMsg) => {
|
||||
const target = safeCustomerId.value
|
||||
const customerData = props.customerData || { name: target, avatar: '' }
|
||||
try {
|
||||
const { res, customerId: sentTo } = await sendCsTextReliable(
|
||||
props.teamId,
|
||||
target,
|
||||
customerData,
|
||||
text
|
||||
)
|
||||
if (sentTo !== target) {
|
||||
throw { content: `收件人不一致:期望 ${target},实际 ${sentTo}` }
|
||||
}
|
||||
sessionReady = true
|
||||
sessionCustomerId = target
|
||||
if (tempMsg) {
|
||||
tempMsg.status = 'success'
|
||||
if (res?.messageId) tempMsg.messageId = res.messageId
|
||||
tempMsg._tempId = null
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (tempMsg) {
|
||||
tempMsg.status = 'failed'
|
||||
tempMsg.failReason = `${error?.code || ''} ${error?.content || error?.message || '发送失败'}`.trim()
|
||||
}
|
||||
ElMessage.error('发送失败: ' + (error?.content || error?.message || '请重试'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendText = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text) return
|
||||
if (!window.goeasy) return ElMessage.error('聊天服务未连接')
|
||||
if (!props.agentOnline && !window.__kefuCsOnline) {
|
||||
ElMessage.warning('客服正在连接,请稍候…')
|
||||
if (!text || sending.value) return
|
||||
|
||||
const normalized = normalizeCustomerImId(props.customerId)
|
||||
if (!normalized.ok) {
|
||||
ElMessage.error(normalized.reason || '客户ID无效,无法发送')
|
||||
return
|
||||
}
|
||||
if (!sessionReady) {
|
||||
if (!window.goeasy || !isGoeasyConnected()) {
|
||||
ElMessage.error('聊天服务未连接,请刷新页面')
|
||||
return
|
||||
}
|
||||
if (!props.agentOnline && !window.__kefuCsOnline) {
|
||||
ElMessage.warning('客服未上线,请先上线后再发')
|
||||
return
|
||||
}
|
||||
|
||||
const target = normalized.id
|
||||
if (!sessionReady || sessionCustomerId !== target) {
|
||||
try {
|
||||
await ensureCustomerAccepted(props.customerId, props.customerData)
|
||||
await ensureCustomerAccepted(target, props.customerData || { name: target, avatar: '' })
|
||||
sessionReady = true
|
||||
sessionCustomerId = target
|
||||
} catch (error) {
|
||||
ElMessage.error('会话未接入:' + (error?.content || '未知错误'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const customerData = props.customerData || { name: props.customerId, avatar: '' }
|
||||
const message = buildCsTextMessage(props.teamId, props.customerId, customerData, text)
|
||||
|
||||
console.log('[ChatDialog] 发送消息 → 用户ID:', props.customerId, message)
|
||||
|
||||
const tempId = 'local-' + Date.now()
|
||||
const tempId = 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
||||
const tempMsg = {
|
||||
messageId: tempId,
|
||||
_tempId: tempId,
|
||||
@@ -238,34 +305,32 @@ const sendText = async () => {
|
||||
timestamp: Date.now(),
|
||||
payload: { text },
|
||||
status: 'sending',
|
||||
showTime: shouldShowTime(Date.now())
|
||||
showTime: shouldShowTime(Date.now()),
|
||||
_targetCustomerId: target
|
||||
}
|
||||
messages.value.push(tempMsg)
|
||||
scrollToBottom()
|
||||
inputText.value = ''
|
||||
sending.value = true
|
||||
try {
|
||||
await doSendText(text, tempMsg)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
window.goeasy.im.sendMessage({
|
||||
message: message,
|
||||
onSuccess: (res) => {
|
||||
console.log('[ChatDialog] ✅ 发送成功', res)
|
||||
const idx = messages.value.findIndex(m => m._tempId === tempId)
|
||||
if (idx > -1) {
|
||||
messages.value[idx].status = 'success'
|
||||
if (res?.messageId) {
|
||||
messages.value[idx].messageId = res.messageId
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[ChatDialog] ❌ 发送失败', error)
|
||||
const idx = messages.value.findIndex(m => m._tempId === tempId)
|
||||
if (idx > -1) {
|
||||
messages.value[idx].status = 'failed'
|
||||
messages.value[idx].failReason = `${error.code}: ${error.content}`
|
||||
}
|
||||
ElMessage.error('发送失败: ' + error.content)
|
||||
}
|
||||
})
|
||||
const resendFailed = async (msg) => {
|
||||
if (!msg || msg.status !== 'failed' || sending.value) return
|
||||
const text = msg.payload?.text
|
||||
if (!text) return
|
||||
msg.status = 'sending'
|
||||
msg.failReason = ''
|
||||
sending.value = true
|
||||
try {
|
||||
await doSendText(text, msg)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowTime = (ts) => {
|
||||
@@ -284,25 +349,37 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
const markRead = () => {
|
||||
const csteam = window.goeasy?.im?.csteam(props.teamId)
|
||||
if (csteam) {
|
||||
csteam.markMessageAsRead({ type: GoEasy.IM_SCENE.CS, id: props.customerId })
|
||||
const target = safeCustomerId.value
|
||||
const team = window.goeasy?.im?.csteam(props.teamId)
|
||||
if (team && target) {
|
||||
team.markMessageAsRead({ type: GoEasy.IM_SCENE.CS, id: target })
|
||||
}
|
||||
}
|
||||
const previewImage = (url) => window.open(url)
|
||||
|
||||
const onDialogOpened = async () => {
|
||||
console.log('[ChatDialog] 弹窗已打开, customerId=', props.customerId)
|
||||
if (!agentId.value) agentId.value = window.__kefuAgentId || 'KF' + localStorage.getItem('username')
|
||||
sessionReady = false
|
||||
sessionCustomerId = ''
|
||||
listeningCustomerId = ''
|
||||
proxy.$emitter.on('kefu-cs-new-message', onGlobalCsMessage)
|
||||
|
||||
const normalized = normalizeCustomerImId(props.customerId)
|
||||
if (!normalized.ok) {
|
||||
ElMessage.error(normalized.reason || '客户ID无效')
|
||||
return
|
||||
}
|
||||
if (normalized.changed) {
|
||||
ElMessage.info(`已纠正客户ID为 ${normalized.id}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCustomerAccepted(
|
||||
props.customerId,
|
||||
props.customerData || { name: props.customerId, avatar: '' }
|
||||
normalized.id,
|
||||
props.customerData || { name: normalized.id, avatar: '' }
|
||||
)
|
||||
sessionReady = true
|
||||
sessionCustomerId = normalized.id
|
||||
} catch (error) {
|
||||
ElMessage.warning('接入会话:' + (error?.content || '请稍后重试'))
|
||||
}
|
||||
@@ -313,15 +390,29 @@ const onDialogOpened = async () => {
|
||||
|
||||
const onDialogClose = () => {
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
listeningCustomerId = ''
|
||||
sessionReady = false
|
||||
sessionCustomerId = ''
|
||||
messages.value = []
|
||||
lastTimestamp.value = null
|
||||
sending.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.customerId,
|
||||
(id, oldId) => {
|
||||
if (!props.visible || !id || id === oldId) return
|
||||
sessionReady = false
|
||||
sessionCustomerId = ''
|
||||
lastTimestamp.value = null
|
||||
messages.value = []
|
||||
onDialogOpened()
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
listeningCustomerId = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -329,6 +420,7 @@ onUnmounted(() => {
|
||||
.chat-container { display: flex; flex-direction: column; height: 500px; }
|
||||
.chat-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.toolbar-title { font-size: 13px; color: #666; }
|
||||
.target-id { margin-left: 8px; color: #409eff; font-family: Consolas, monospace; }
|
||||
.msg-list { flex: 1; overflow-y: auto; background: rgba(0,0,0,0.03); border-radius: 8px; padding: 10px; margin-bottom: 10px; }
|
||||
.msg-item { margin-bottom: 8px; }
|
||||
.msg-time { text-align: center; font-size: 12px; color: #999; margin: 8px 0; }
|
||||
@@ -340,7 +432,8 @@ onUnmounted(() => {
|
||||
.msg-text { font-size: 14px; color: #333; }
|
||||
.msg-image { cursor: pointer; }
|
||||
.msg-order { background: #f0f0f0; padding: 8px; border-radius: 8px; font-size: 13px; }
|
||||
.send-failed { text-align: right; margin-top: 2px; margin-right: 42px; cursor: pointer; }
|
||||
.send-failed { text-align: right; margin-top: 2px; margin-right: 42px; cursor: pointer; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
.resend-tip { font-size: 12px; color: #f56c6c; }
|
||||
.send-sending { text-align: right; margin-top: 2px; margin-right: 42px; }
|
||||
.input-area { padding: 8px 0; }
|
||||
.loading-tip { text-align: center; color: #aaa; font-size: 13px; padding: 10px; }
|
||||
|
||||
@@ -213,6 +213,11 @@ const routes = [
|
||||
component: () => import('@/views/punishment/Detail.vue'),
|
||||
meta: { title: '处罚详情' }
|
||||
},
|
||||
{
|
||||
path: 'gongdan',
|
||||
component: () => import('@/views/gongdan/List.vue'),
|
||||
meta: { title: '投诉工单', perm: 'gdck666' }
|
||||
},
|
||||
|
||||
// ========== 商品管理(原有) ==========
|
||||
{
|
||||
|
||||
@@ -143,6 +143,7 @@ const KEFU_MENU_ROWS = [
|
||||
{ page_id: 'withdraw.data', parent_id: 'withdraw', path: '/withdraw/data', sort_order: 63, perm_codes: ['005bb'] },
|
||||
{ page_id: 'withdraw.settings', parent_id: 'withdraw', path: '/withdraw/settings', sort_order: 64, perm_codes: ['5500a', '5500b', '5500c'] },
|
||||
{ page_id: 'punishment', parent_id: '', path: '/punishment', sort_order: 80, perm_codes: ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs'] },
|
||||
{ page_id: 'gongdan', parent_id: '', path: '/gongdan', sort_order: 81, perm_codes: ['gdck666', 'gdcl666'] },
|
||||
{ page_id: 'product.list', parent_id: 'product', path: '/product/list', sort_order: 91, perm_codes: ['2200a'] },
|
||||
{ page_id: 'product.type-zone', parent_id: 'product', path: '/product/type-zone', sort_order: 92, perm_codes: ['7007a'] },
|
||||
{ page_id: 'product.data', parent_id: 'product', path: '/product/data', sort_order: 93, perm_codes: ['2200a'] },
|
||||
@@ -218,12 +219,20 @@ export function canShowMenuPage(pageId) {
|
||||
const access = getMenuAccess();
|
||||
if (!access) return false;
|
||||
const ids = access?.visible_page_ids;
|
||||
if (!Array.isArray(ids) || ids.length === 0) return false;
|
||||
|
||||
// 侧栏以服务端 visible_page_ids 为准;permission_codes 由页面/API 二次校验
|
||||
if (ids.includes(pageId)) return true;
|
||||
const children = KEFU_MENU_PARENT_IDS[pageId];
|
||||
return children ? children.some((id) => ids.includes(id)) : false;
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
if (ids.includes(pageId)) return true;
|
||||
const children = KEFU_MENU_PARENT_IDS[pageId];
|
||||
if (children && children.some((id) => ids.includes(id))) return true;
|
||||
// 兼容尚未 seed 的新菜单:本地已定义 + 用户有对应权限(或超管)
|
||||
const row = KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
|
||||
if (!row) return false;
|
||||
if (access.is_super) return true;
|
||||
return pageAllowedByCodes(pageId, access.permission_codes || []);
|
||||
}
|
||||
if (access.is_super || access.is_group_admin) {
|
||||
return !!KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
|
||||
}
|
||||
return pageAllowedByCodes(pageId, access.permission_codes || []);
|
||||
}
|
||||
|
||||
/** 写入菜单缓存,并同步服务端校正后的 effective_club_id */
|
||||
@@ -370,6 +379,8 @@ export const JITUAN_API = {
|
||||
kefuCfgl: '/jituan/houtai/kefu-cfgl',
|
||||
adminAssignments: '/jituan/houtai/admin-assignments',
|
||||
clubManage: '/jituan/houtai/club-manage',
|
||||
paymentChannelManage: '/jituan/houtai/payment-channel-manage',
|
||||
clubCertUpload: '/jituan/houtai/club-cert-upload',
|
||||
memberList: '/jituan/houtai/hthqhylb',
|
||||
memberUpdate: '/jituan/houtai/htxghyxx',
|
||||
memberAdd: '/jituan/houtai/httjhy',
|
||||
|
||||
@@ -2,8 +2,20 @@ import GoEasy from 'goeasy'
|
||||
|
||||
export const KEFU_TEAM_ID = 'support_team'
|
||||
|
||||
/** 与小程序 im-user.js 一致:仅 Boss / Ds / Sj;管事/组长统一 Ds */
|
||||
export const VALID_CUSTOMER_PREFIXES = ['Boss', 'Ds', 'Sj']
|
||||
const LEGACY_PREFIX_ALIAS = {
|
||||
Gs: 'Ds',
|
||||
Zz: 'Ds',
|
||||
Guanshi: 'Ds',
|
||||
Zuzhang: 'Ds',
|
||||
Kaoheguan: 'Ds'
|
||||
}
|
||||
|
||||
const NOTIFY_DEBOUNCE_MS = 2500
|
||||
const ONLINE_TIMEOUT_MS = 12000
|
||||
const SEND_MAX_RETRY = 3
|
||||
const SEND_RETRY_BASE_MS = 800
|
||||
|
||||
let lastNotifyAt = 0
|
||||
let onlineInFlight = null
|
||||
@@ -23,6 +35,65 @@ export function isGoeasyConnected() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化客户 GoEasy ID,避免 Gs/Zz 等已废弃前缀发到空号
|
||||
* @returns {{ ok: boolean, id: string, changed: boolean, reason?: string }}
|
||||
*/
|
||||
export function normalizeCustomerImId(rawId) {
|
||||
const raw = String(rawId || '').trim()
|
||||
if (!raw) {
|
||||
return { ok: false, id: '', changed: false, reason: '缺少客户ID' }
|
||||
}
|
||||
if (/^KF/i.test(raw) || raw === KEFU_TEAM_ID) {
|
||||
return { ok: false, id: raw, changed: false, reason: '不能向客服账号发消息' }
|
||||
}
|
||||
|
||||
const m = raw.match(/^(Boss|Ds|Sj|Gs|Zz|Guanshi|Zuzhang|Kaoheguan)(\d+)$/i)
|
||||
if (m) {
|
||||
const prefixRaw = m[1]
|
||||
const uid = m[2]
|
||||
const canonKey = Object.keys(LEGACY_PREFIX_ALIAS).find(
|
||||
(k) => k.toLowerCase() === prefixRaw.toLowerCase()
|
||||
)
|
||||
let prefix = canonKey ? LEGACY_PREFIX_ALIAS[canonKey] : null
|
||||
if (!prefix) {
|
||||
const hit = VALID_CUSTOMER_PREFIXES.find((p) => p.toLowerCase() === prefixRaw.toLowerCase())
|
||||
prefix = hit || null
|
||||
}
|
||||
if (!prefix) {
|
||||
return { ok: false, id: raw, changed: false, reason: '不支持的身份前缀' }
|
||||
}
|
||||
const id = prefix + uid
|
||||
return { ok: true, id, changed: id !== raw }
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(raw)) {
|
||||
return { ok: false, id: raw, changed: false, reason: '请选择身份前缀(打手/老板/商家)' }
|
||||
}
|
||||
|
||||
// 会话列表偶发带额外字段时,尽量抽出标准 ID
|
||||
const embedded = raw.match(/(Boss|Ds|Sj)\d+/i)
|
||||
if (embedded) {
|
||||
return normalizeCustomerImId(embedded[0])
|
||||
}
|
||||
|
||||
return { ok: false, id: raw, changed: false, reason: '客户ID格式无效' }
|
||||
}
|
||||
|
||||
export function isValidCustomerImId(id) {
|
||||
return normalizeCustomerImId(id).ok
|
||||
}
|
||||
|
||||
export function resolveConversationCustomerId(conv) {
|
||||
if (!conv) return ''
|
||||
const candidates = [conv.id, conv.userId, conv.customerId, conv.data?.id]
|
||||
for (const c of candidates) {
|
||||
const n = normalizeCustomerImId(c)
|
||||
if (n.ok) return n.id
|
||||
}
|
||||
return String(conv.id || conv.userId || '').trim()
|
||||
}
|
||||
|
||||
export function calcUnreadFromConversations(conversations = []) {
|
||||
return conversations.reduce((sum, c) => sum + (c.unread || 0), 0)
|
||||
}
|
||||
@@ -238,25 +309,42 @@ export function getCsMessageCustomerId(message, teamId = KEFU_TEAM_ID, agentId)
|
||||
const tid = message.teamId || teamId
|
||||
const aid = agentId || window.__kefuAgentId
|
||||
const sender = message.senderId
|
||||
const to = message.to || message.receiverId
|
||||
const toId = typeof message.to === 'object' ? message.to?.id : (message.to || message.receiverId)
|
||||
|
||||
const pick = (raw) => {
|
||||
const n = normalizeCustomerImId(raw)
|
||||
return n.ok ? n.id : null
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof message.customerId === 'function') {
|
||||
const cid = message.customerId()
|
||||
if (cid && cid !== tid) return cid
|
||||
const cid = pick(message.customerId())
|
||||
if (cid) return cid
|
||||
} else if (message.customerId) {
|
||||
const cid = pick(message.customerId)
|
||||
if (cid) 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
|
||||
// 优先匹配标准客户 ID,避免误判成 team/agent
|
||||
for (const p of [sender, toId]) {
|
||||
const cid = pick(p)
|
||||
if (cid && cid !== tid && cid !== aid) return cid
|
||||
}
|
||||
if (sender === aid && to) return to
|
||||
if (to === aid && sender) return sender
|
||||
if (sender === tid && to) return to
|
||||
if (to === tid && sender) return sender
|
||||
|
||||
const parties = [sender, toId].filter(Boolean)
|
||||
for (const p of parties) {
|
||||
if (p !== tid && p !== aid && !String(p).startsWith('KF')) {
|
||||
const n = normalizeCustomerImId(p)
|
||||
return n.ok ? n.id : p
|
||||
}
|
||||
}
|
||||
if (sender === aid && toId) return pick(toId) || toId
|
||||
if (toId === aid && sender) return pick(sender) || sender
|
||||
if (sender === tid && toId) return pick(toId) || toId
|
||||
if (toId === tid && sender) return pick(sender) || sender
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -264,16 +352,25 @@ export function isCsMessageForCustomer(message, customerId, teamId = KEFU_TEAM_I
|
||||
if (!message || !customerId) return false
|
||||
if (message.teamId && message.teamId !== teamId) return false
|
||||
const cid = getCsMessageCustomerId(message, teamId, agentId)
|
||||
if (!cid) return false
|
||||
const expected = normalizeCustomerImId(customerId)
|
||||
const actual = normalizeCustomerImId(cid)
|
||||
if (expected.ok && actual.ok) return expected.id === actual.id
|
||||
return cid === customerId
|
||||
}
|
||||
|
||||
export function buildCsTextMessage(teamId, customerId, customerData, text) {
|
||||
const normalized = normalizeCustomerImId(customerId)
|
||||
if (!normalized.ok) {
|
||||
throw new Error(normalized.reason || '客户ID无效')
|
||||
}
|
||||
const targetId = normalized.id
|
||||
const message = window.goeasy.im.createTextMessage({
|
||||
text,
|
||||
to: {
|
||||
type: GoEasy.IM_SCENE.CS,
|
||||
id: customerId,
|
||||
data: customerData || { name: customerId, avatar: '' }
|
||||
id: targetId,
|
||||
data: customerData || { name: targetId, avatar: '' }
|
||||
}
|
||||
})
|
||||
message.teamId = teamId
|
||||
@@ -281,9 +378,109 @@ export function buildCsTextMessage(teamId, customerId, customerData, text) {
|
||||
return message
|
||||
}
|
||||
|
||||
function ensureCsOnlinePromise({ phone, force = false, emitter } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ensureCsOnline({
|
||||
phone,
|
||||
force,
|
||||
emitter,
|
||||
onSuccess: () => resolve(true),
|
||||
onFailed: (error) => reject(error || { content: '客服上线失败' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 发送前:连线 + 客服在线 + accept 指定客户 */
|
||||
export async function prepareCsSend(customerId, customerData, { forceOnline = false } = {}) {
|
||||
const normalized = normalizeCustomerImId(customerId)
|
||||
if (!normalized.ok) {
|
||||
throw { content: normalized.reason || '客户ID无效' }
|
||||
}
|
||||
if (!isGoeasyConnected() || !window.goeasy?.im) {
|
||||
throw { content: '消息服务未连接,请刷新后重试' }
|
||||
}
|
||||
|
||||
const phone = (window.__kefuAgentId || '').replace(/^KF/, '') || localStorage.getItem('username') || ''
|
||||
await ensureCsOnlinePromise({ phone, force: forceOnline })
|
||||
|
||||
const online = await checkCsOnlineStatus()
|
||||
if (!online) {
|
||||
await ensureCsOnlinePromise({ phone, force: true })
|
||||
const again = await checkCsOnlineStatus()
|
||||
if (!again) throw { content: '客服未上线,请先点「上线」' }
|
||||
}
|
||||
|
||||
await ensureCustomerAccepted(normalized.id, customerData || { name: normalized.id, avatar: '' })
|
||||
return normalized.id
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isRetryableSendError(error) {
|
||||
const code = String(error?.code || '')
|
||||
const msg = String(error?.content || error?.message || '').toLowerCase()
|
||||
if (msg.includes('online') || msg.includes('上线') || msg.includes('连接') || msg.includes('timeout')) return true
|
||||
if (msg.includes('network') || msg.includes('disconnect') || msg.includes('not connected')) return true
|
||||
if (code === '503' || code === '408' || code === '500') return true
|
||||
return false
|
||||
}
|
||||
|
||||
function sendMessageOnce(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.goeasy?.im) {
|
||||
reject({ content: '消息服务未连接' })
|
||||
return
|
||||
}
|
||||
window.goeasy.im.sendMessage({
|
||||
message,
|
||||
onSuccess: (res) => resolve(res || message),
|
||||
onFailed: (error) => reject(error || { content: '发送失败' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定客户发送文本(带 ID 校验、上线/接入、有限重试)
|
||||
* 保证 to.id 始终是规范化后的客户 ID
|
||||
*/
|
||||
export async function sendCsTextReliable(teamId, customerId, customerData, text, { maxRetry = SEND_MAX_RETRY } = {}) {
|
||||
const targetId = await prepareCsSend(customerId, customerData)
|
||||
let lastError = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||
try {
|
||||
if (!isGoeasyConnected()) {
|
||||
throw { content: '消息服务未连接' }
|
||||
}
|
||||
if (attempt > 1) {
|
||||
await prepareCsSend(targetId, customerData, { forceOnline: true })
|
||||
}
|
||||
const message = buildCsTextMessage(teamId || KEFU_TEAM_ID, targetId, customerData, text)
|
||||
if (message?.to?.id && message.to.id !== targetId) {
|
||||
throw { content: `收件人校验失败:期望 ${targetId}` }
|
||||
}
|
||||
const res = await sendMessageOnce(message)
|
||||
return { res, customerId: targetId, message }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt >= maxRetry || !isRetryableSendError(error)) break
|
||||
await sleep(SEND_RETRY_BASE_MS * attempt)
|
||||
}
|
||||
}
|
||||
throw lastError || { content: '发送失败' }
|
||||
}
|
||||
|
||||
export function ensureCustomerAccepted(customerId, customerData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.goeasy?.im || !customerId) {
|
||||
const normalized = normalizeCustomerImId(customerId)
|
||||
if (!normalized.ok) {
|
||||
reject({ content: normalized.reason || '客户ID无效' })
|
||||
return
|
||||
}
|
||||
const targetId = normalized.id
|
||||
if (!window.goeasy?.im) {
|
||||
reject({ content: '连接未就绪' })
|
||||
return
|
||||
}
|
||||
@@ -298,14 +495,14 @@ export function ensureCustomerAccepted(customerId, customerData) {
|
||||
}
|
||||
csteam.accept({
|
||||
customer: {
|
||||
id: customerId,
|
||||
data: customerData || { name: customerId, avatar: '' }
|
||||
id: targetId,
|
||||
data: customerData || { name: targetId, avatar: '' }
|
||||
},
|
||||
onSuccess: () => resolve(),
|
||||
onSuccess: () => resolve(targetId),
|
||||
onFailed: (error) => {
|
||||
const msg = (error?.content || '').toLowerCase()
|
||||
if (msg.includes('already') || msg.includes('exist') || msg.includes('已')) {
|
||||
resolve()
|
||||
resolve(targetId)
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="俱乐部主数据与小程序/支付/GoEasy 密钥"
|
||||
description="仅超级管理员可编辑。修改后影响该俱乐部小程序登录、支付回调、IM 等,请谨慎操作。"
|
||||
description="仅超级管理员可编辑。收款商户与提现商户可分开配置;证书支持直接上传,无需再手填服务器路径。"
|
||||
class="tip"
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-form v-loading="loading" :model="form" label-width="140px" class="config-form">
|
||||
<el-form v-loading="loading" :model="form" label-width="150px" class="config-form">
|
||||
<el-divider content-position="left">小程序微信</el-divider>
|
||||
<el-form-item label="wx_appid">
|
||||
<el-input v-model="form.wx_appid" placeholder="小程序 AppID" />
|
||||
@@ -50,31 +50,153 @@
|
||||
<el-input v-model="form.wx_secret" type="password" show-password placeholder="小程序 Secret" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">微信支付(同一商户号,V2 收款 / V3 提现 密钥分开配)</el-divider>
|
||||
<el-form-item label="商户号 mch_id">
|
||||
<el-input v-model="form.mch_id" />
|
||||
<!-- ========== 收款商户(点单/会员 V2) ========== -->
|
||||
<el-divider content-position="left">收款商户(点单 / 会员 / 押金 · V2)</el-divider>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="section-tip"
|
||||
title="专门标记为收款商户:下单走这套;退款应对齐这套商户与证书。"
|
||||
/>
|
||||
<el-form-item label="收款商户号 mch_id">
|
||||
<el-input v-model="form.mch_id" placeholder="微信支付商户号(收款)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="pay_app_id">
|
||||
<el-input v-model="form.pay_app_id" placeholder="可与 wx_appid 相同,或留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="APIv2 密钥 mch_key">
|
||||
<el-form-item label="收款 APIv2 密钥">
|
||||
<el-input
|
||||
v-model="form.mch_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="小程序 JSAPI 收款(会员/点单/押金等)"
|
||||
placeholder="商户平台 → API安全 → APIv2密钥"
|
||||
/>
|
||||
<div class="field-help">微信商户平台 → API安全 → 设置APIv2密钥。与下方 APIv3 不是同一个。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="APIv3 密钥 api_v3_key">
|
||||
<el-form-item label="收款私钥 key.pem">
|
||||
<div class="upload-row">
|
||||
<el-input v-model="form.pay_key_path" placeholder="上传后自动填入路径" readonly />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => uploadCert('pay_key', opt)"
|
||||
accept=".pem,.key"
|
||||
>
|
||||
<el-button :loading="uploadingKind === 'pay_key'">上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="收款证书 cert.pem">
|
||||
<div class="upload-row">
|
||||
<el-input v-model="form.pay_cert_path" placeholder="上传后自动填入路径" readonly />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => uploadCert('pay_cert', opt)"
|
||||
accept=".pem,.crt"
|
||||
>
|
||||
<el-button :loading="uploadingKind === 'pay_cert'">上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- ========== 提现商户(V3) ========== -->
|
||||
<el-divider content-position="left">提现商户(转账到零钱 · V3,可与收款不同)</el-divider>
|
||||
<el-form-item label="提现商户号">
|
||||
<el-input
|
||||
v-model="form.withdraw_mch_id"
|
||||
placeholder="留空则回落收款商户号 mch_id"
|
||||
/>
|
||||
<div class="field-help">与收款商户不同时必填;相同可留空。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="提现 APIv3 密钥">
|
||||
<el-input
|
||||
v-model="form.api_v3_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="自动提现 / 转账到零钱 / 回调解密"
|
||||
placeholder="商户平台 → API安全 → APIv3密钥"
|
||||
/>
|
||||
<div class="field-help">微信商户平台 → API安全 → 设置APIv3密钥。仅用于提现,不用于小程序收款。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="提现私钥 key.pem">
|
||||
<div class="upload-row">
|
||||
<el-input v-model="form.private_key_path" placeholder="上传后自动填入路径" readonly />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => uploadCert('withdraw_key', opt)"
|
||||
accept=".pem,.key"
|
||||
>
|
||||
<el-button :loading="uploadingKind === 'withdraw_key'">上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="提现证书 cert.pem">
|
||||
<div class="upload-row">
|
||||
<el-input :model-value="withdrawCertHint" placeholder="与私钥同目录,上传后自动配对序列号" readonly />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => uploadCert('withdraw_cert', opt)"
|
||||
accept=".pem,.crt"
|
||||
>
|
||||
<el-button :loading="uploadingKind === 'withdraw_cert'">上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台证书/公钥">
|
||||
<div class="upload-row">
|
||||
<el-input v-model="form.platform_cert_dir" placeholder="上传后自动填入目录" readonly />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => uploadCert('withdraw_platform', opt)"
|
||||
accept=".pem"
|
||||
>
|
||||
<el-button :loading="uploadingKind === 'withdraw_platform'">上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="field-help">回调验签用;存为提现目录下的 pub_key.pem,字段记录目录。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="证书序列号">
|
||||
<el-input v-model="form.cert_serial_no" placeholder="一般可留空,系统按私钥自动配对" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- ========== 小程序收款通道:微信直连 / 付呗 ========== -->
|
||||
<el-divider content-position="left">小程序收款通道(微信直连 / 付呗)</el-divider>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="section-tip"
|
||||
title="前端仍走「微信支付」;此处选择实际下单走微信商户号还是付呗。默认微信直连,不改不影响现网。"
|
||||
/>
|
||||
<el-form-item label="当前选用">
|
||||
<el-radio-group v-model="form.mini_pay_channel">
|
||||
<el-radio label="wechat">微信直连商户号</el-radio>
|
||||
<el-radio label="fubei">付呗</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="field-help">保存俱乐部配置时会一并写入;也可点下方「仅切换通道」立即生效。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="付呗开放平台ID">
|
||||
<el-input v-model="fubeiForm.app_id" placeholder="如 202607111990887" />
|
||||
</el-form-item>
|
||||
<el-form-item label="付呗接口密钥">
|
||||
<el-input
|
||||
v-model="fubeiForm.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="fubeiForm.api_key_configured ? `已配置 ${fubeiForm.api_key_masked},留空则不修改` : '付呗 app_secret'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="付呗门店ID">
|
||||
<el-input v-model="fubeiForm.store_id" placeholder="如 2384931" />
|
||||
</el-form-item>
|
||||
<el-form-item label="付呗商户ID">
|
||||
<el-input v-model="fubeiForm.merchant_id" placeholder="如 3245035(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付小程序AppID">
|
||||
<el-input v-model="fubeiForm.mini_app_id" placeholder="可与 wx_appid 相同" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" plain :loading="fubeiSaving" @click="saveFubeiOnly">保存付呗配置</el-button>
|
||||
<el-button :loading="channelSwitching" @click="switchChannelOnly">仅切换通道</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="payWarnings.length"
|
||||
type="warning"
|
||||
@@ -83,15 +205,6 @@
|
||||
class="pay-warn"
|
||||
:title="payWarnings.join(';')"
|
||||
/>
|
||||
<el-form-item label="cert_serial_no">
|
||||
<el-input v-model="form.cert_serial_no" placeholder="APIv3 商户证书序列号(提现签名用)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="private_key_path">
|
||||
<el-input v-model="form.private_key_path" placeholder="服务器上 apiclient_key.pem 路径(提现签名用)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="platform_cert_dir">
|
||||
<el-input v-model="form.platform_cert_dir" placeholder="微信平台证书目录(提现回调验签用)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">服务号</el-divider>
|
||||
<el-form-item label="official_appid">
|
||||
@@ -131,7 +244,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -141,6 +254,7 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const creating = ref(false)
|
||||
const createVisible = ref(false)
|
||||
const uploadingKind = ref('')
|
||||
const clubList = ref([])
|
||||
const currentClubId = ref('xq')
|
||||
const createForm = reactive({
|
||||
@@ -150,6 +264,19 @@ const createForm = reactive({
|
||||
from_club: 'xq',
|
||||
})
|
||||
const payWarnings = ref([])
|
||||
const fubeiSaving = ref(false)
|
||||
const channelSwitching = ref(false)
|
||||
const fubeiForm = reactive({
|
||||
id: null,
|
||||
name: '主付呗',
|
||||
app_id: '',
|
||||
api_key: '',
|
||||
api_key_masked: '',
|
||||
api_key_configured: false,
|
||||
store_id: '',
|
||||
merchant_id: '',
|
||||
mini_app_id: '',
|
||||
})
|
||||
const form = reactive({
|
||||
name: '',
|
||||
wx_appid: '',
|
||||
@@ -157,6 +284,10 @@ const form = reactive({
|
||||
mch_id: '',
|
||||
pay_app_id: '',
|
||||
mch_key: '',
|
||||
pay_cert_path: '',
|
||||
pay_key_path: '',
|
||||
withdraw_mch_id: '',
|
||||
mini_pay_channel: 'wechat',
|
||||
api_v3_key: '',
|
||||
cert_serial_no: '',
|
||||
private_key_path: '',
|
||||
@@ -177,6 +308,15 @@ const form = reactive({
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const withdrawCertHint = computed(() => {
|
||||
const key = form.private_key_path || ''
|
||||
if (!key) return ''
|
||||
if (key.includes('apiclient_key.pem')) {
|
||||
return key.replace('apiclient_key.pem', 'apiclient_cert.pem')
|
||||
}
|
||||
return key
|
||||
})
|
||||
|
||||
const loadClubList = async () => {
|
||||
const res = await request.post(JITUAN_API.clubManage, { phone, action: 'list' })
|
||||
if (res.code === 0) {
|
||||
@@ -197,8 +337,15 @@ const loadClub = async () => {
|
||||
club_id: currentClubId.value,
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
Object.assign(form, {
|
||||
pay_cert_path: '',
|
||||
pay_key_path: '',
|
||||
withdraw_mch_id: '',
|
||||
mini_pay_channel: 'wechat',
|
||||
...res.data,
|
||||
})
|
||||
payWarnings.value = (res.data.pay_config && res.data.pay_config.warnings) || []
|
||||
await loadFubeiChannel()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
@@ -209,6 +356,130 @@ const loadClub = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadFubeiChannel = async () => {
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.paymentChannelManage, {
|
||||
phone,
|
||||
action: 'get',
|
||||
club_id: currentClubId.value,
|
||||
})
|
||||
if (res.code !== 0) return
|
||||
if (res.data?.mini_pay_channel) {
|
||||
form.mini_pay_channel = res.data.mini_pay_channel
|
||||
}
|
||||
const list = res.data?.fubei_channels || []
|
||||
const row = list[0]
|
||||
if (row) {
|
||||
Object.assign(fubeiForm, {
|
||||
id: row.id,
|
||||
name: row.name || '主付呗',
|
||||
app_id: row.app_id || '',
|
||||
api_key: '',
|
||||
api_key_masked: row.api_key_masked || '',
|
||||
api_key_configured: !!row.api_key_configured,
|
||||
store_id: row.store_id != null ? String(row.store_id) : '',
|
||||
merchant_id: row.merchant_id || '',
|
||||
mini_app_id: row.mini_app_id || '',
|
||||
})
|
||||
} else {
|
||||
Object.assign(fubeiForm, {
|
||||
id: null,
|
||||
name: '主付呗',
|
||||
app_id: '',
|
||||
api_key: '',
|
||||
api_key_masked: '',
|
||||
api_key_configured: false,
|
||||
store_id: '',
|
||||
merchant_id: '',
|
||||
mini_app_id: form.wx_appid || '',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* 旧后端无此接口时忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
const saveFubeiOnly = async () => {
|
||||
fubeiSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
phone,
|
||||
action: 'save_fubei',
|
||||
club_id: currentClubId.value,
|
||||
id: fubeiForm.id,
|
||||
name: fubeiForm.name || '主付呗',
|
||||
app_id: fubeiForm.app_id,
|
||||
store_id: fubeiForm.store_id,
|
||||
merchant_id: fubeiForm.merchant_id,
|
||||
mini_app_id: fubeiForm.mini_app_id || form.wx_appid,
|
||||
is_default: true,
|
||||
activate: form.mini_pay_channel === 'fubei',
|
||||
}
|
||||
if (fubeiForm.api_key) payload.api_key = fubeiForm.api_key
|
||||
const res = await request.post(JITUAN_API.paymentChannelManage, payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '付呗配置已保存')
|
||||
await loadFubeiChannel()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存付呗配置失败')
|
||||
} finally {
|
||||
fubeiSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchChannelOnly = async () => {
|
||||
channelSwitching.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.paymentChannelManage, {
|
||||
phone,
|
||||
action: 'set_mini_pay_channel',
|
||||
club_id: currentClubId.value,
|
||||
mini_pay_channel: form.mini_pay_channel,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '通道已切换')
|
||||
if (res.warning) ElMessage.warning('请先完善付呗配置')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '切换失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('切换通道失败')
|
||||
} finally {
|
||||
channelSwitching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const uploadCert = async (kind, opt) => {
|
||||
const file = opt?.file
|
||||
if (!file || !currentClubId.value) return
|
||||
uploadingKind.value = kind
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('phone', phone || '')
|
||||
fd.append('club_id', currentClubId.value)
|
||||
fd.append('kind', kind)
|
||||
fd.append('file', file)
|
||||
const res = await request.post(JITUAN_API.clubCertUpload, fd)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
const d = res.data || {}
|
||||
if (d.pay_key_path !== undefined) form.pay_key_path = d.pay_key_path || ''
|
||||
if (d.pay_cert_path !== undefined) form.pay_cert_path = d.pay_cert_path || ''
|
||||
if (d.private_key_path !== undefined) form.private_key_path = d.private_key_path || ''
|
||||
if (d.platform_cert_dir !== undefined) form.platform_cert_dir = d.platform_cert_dir || ''
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploadingKind.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveClub = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -221,6 +492,7 @@ const saveClub = async () => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
await loadClubList()
|
||||
await loadClub()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
@@ -264,7 +536,7 @@ const createClub = async () => {
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('创建失败,请确认账号有 000001 权限且后端已部署')
|
||||
ElMessage.error('创建失败,请确认账号有权限且后端已部署')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -283,6 +555,9 @@ onMounted(async () => {
|
||||
.tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -290,7 +565,7 @@ onMounted(async () => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.config-form {
|
||||
max-width: 720px;
|
||||
max-width: 820px;
|
||||
}
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
@@ -299,6 +574,15 @@ onMounted(async () => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.pay-warn {
|
||||
margin-bottom: 16px;
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
.upload-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.upload-row .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
<el-button :disabled="!searchRole || !searchUid" @click="startNewChat">发起会话</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="search-tip">示例:选择“打手”,输入123456,将联系 Ds123456</div>
|
||||
<div class="search-tip">
|
||||
身份与小程序一致:打手/管事/组长 → Ds,老板 → Boss,商家 → Sj。示例:打手 + 123456 = Ds123456
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,17 +142,17 @@ import {
|
||||
goOnlineManually,
|
||||
goOfflineManually,
|
||||
isGoeasyConnected,
|
||||
checkCsOnlineStatus
|
||||
checkCsOnlineStatus,
|
||||
normalizeCustomerImId,
|
||||
resolveConversationCustomerId
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const availableRoles = [
|
||||
{ prefix: 'Ds', name: '打手' },
|
||||
{ prefix: 'Ds', name: '打手/管事/组长' },
|
||||
{ prefix: 'Boss', name: '老板' },
|
||||
{ prefix: 'Gs', name: '管事' },
|
||||
{ prefix: 'Sj', name: '商家' },
|
||||
{ prefix: 'Zz', name: '组长' }
|
||||
{ prefix: 'Sj', name: '商家' }
|
||||
]
|
||||
|
||||
const goeasyReady = ref(isGoeasyConnected())
|
||||
@@ -190,7 +192,10 @@ function getCsteamLocal() {
|
||||
function filterConversations(convs) {
|
||||
if (activeRole.value === 'all') return convs
|
||||
return convs.filter(c => {
|
||||
const userId = c.userId || c.id || ''
|
||||
const userId = resolveConversationCustomerId(c) || c.userId || c.id || ''
|
||||
if (activeRole.value === 'Ds') {
|
||||
return /^(Ds|Gs|Zz)/i.test(userId)
|
||||
}
|
||||
return userId.startsWith(activeRole.value)
|
||||
})
|
||||
}
|
||||
@@ -311,8 +316,21 @@ const acceptAndOpenChat = async (conv, openDialog = true) => {
|
||||
ElMessage.warning('请先上线后再接入会话')
|
||||
return
|
||||
}
|
||||
const customerId = conv.id || conv.userId
|
||||
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
|
||||
const rawId = resolveConversationCustomerId(conv)
|
||||
const normalized = normalizeCustomerImId(rawId)
|
||||
if (!normalized.ok) {
|
||||
ElMessage.error(normalized.reason || '客户ID无效,无法接入')
|
||||
return
|
||||
}
|
||||
if (normalized.changed) {
|
||||
ElMessage.info(`已纠正客户ID:${rawId} → ${normalized.id}`)
|
||||
}
|
||||
const customerId = normalized.id
|
||||
const customerData = {
|
||||
...(conv.data || {}),
|
||||
name: conv.data?.name || customerId,
|
||||
avatar: conv.data?.avatar || defaultAvatar
|
||||
}
|
||||
try {
|
||||
await ensureCustomerAccepted(customerId, customerData)
|
||||
ElMessage.success('已接入')
|
||||
@@ -348,8 +366,21 @@ const endConversation = (conv) => {
|
||||
}
|
||||
|
||||
const openChat = async (conv) => {
|
||||
const customerId = conv.id || conv.userId
|
||||
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
|
||||
const rawId = resolveConversationCustomerId(conv)
|
||||
const normalized = normalizeCustomerImId(rawId)
|
||||
if (!normalized.ok) {
|
||||
ElMessage.error(normalized.reason || '客户ID无效')
|
||||
return
|
||||
}
|
||||
if (normalized.changed) {
|
||||
ElMessage.info(`已纠正客户ID:${rawId} → ${normalized.id}`)
|
||||
}
|
||||
const customerId = normalized.id
|
||||
const customerData = {
|
||||
...(conv.data || {}),
|
||||
name: conv.data?.name || customerId,
|
||||
avatar: conv.data?.avatar || defaultAvatar
|
||||
}
|
||||
if (!goeasyReady.value) {
|
||||
ElMessage.warning('消息服务连接中,请稍候…')
|
||||
return
|
||||
@@ -385,14 +416,19 @@ const startNewChat = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const fullId = searchRole.value + uid
|
||||
const active = activeList.value.find(c => (c.id === fullId || c.userId === fullId))
|
||||
const normalized = normalizeCustomerImId(searchRole.value + uid)
|
||||
if (!normalized.ok) {
|
||||
ElMessage.warning(normalized.reason || '客户ID无效')
|
||||
return
|
||||
}
|
||||
const fullId = normalized.id
|
||||
const active = activeList.value.find(c => resolveConversationCustomerId(c) === fullId)
|
||||
if (active) {
|
||||
openChat(active)
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingList.value.find(c => c.id === fullId)
|
||||
const pending = pendingList.value.find(c => resolveConversationCustomerId(c) === fullId)
|
||||
if (pending) {
|
||||
acceptAndOpenChat(pending, true)
|
||||
return
|
||||
|
||||
729
src/views/gongdan/List.vue
Normal file
729
src/views/gongdan/List.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<template>
|
||||
<div class="gongdan-page">
|
||||
<div class="stat-row">
|
||||
<div class="stat-card danger" @click="quickFilter(1)">
|
||||
<div class="stat-label">待处理</div>
|
||||
<div class="stat-num">{{ stats.pending }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card warn" @click="quickFilter(3)">
|
||||
<div class="stat-label">不满意</div>
|
||||
<div class="stat-num">{{ stats.dissatisfied }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card escalate" @click="filterEscalated">
|
||||
<div class="stat-label">二次投诉待办</div>
|
||||
<div class="stat-num">{{ stats.escalated }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card need" @click="filterNeedHandle">
|
||||
<div class="stat-label">需处理合计</div>
|
||||
<div class="stat-num">{{ stats.need_handle }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card ok" @click="quickFilter(2)">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-num">{{ stats.done }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card muted" @click="resetFilters">
|
||||
<div class="stat-label">全部工单</div>
|
||||
<div class="stat-num">{{ stats.total }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.zhuangtai" clearable placeholder="全部" style="width:140px">
|
||||
<el-option label="处理中" :value="1" />
|
||||
<el-option label="已处理" :value="2" />
|
||||
<el-option label="不满意" :value="3" />
|
||||
<el-option label="已关闭" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.leixing" clearable placeholder="全部" style="width:140px">
|
||||
<el-option label="订单投诉" :value="1" />
|
||||
<el-option label="充值投诉" :value="2" />
|
||||
<el-option label="罚款投诉" :value="3" />
|
||||
<el-option label="管事投诉" :value="4" />
|
||||
<el-option label="被骗投诉" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="工单号/用户/订单ID/充值ID"
|
||||
style="width:240px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="search">搜索</el-button>
|
||||
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
row-key="gongdan_id"
|
||||
:row-class-name="rowClassName"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<el-table-column label="工单号" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="id-cell">
|
||||
<span class="mono">{{ row.gongdan_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.gongdan_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="badge-row">
|
||||
<el-tag v-if="isPending(row)" type="danger" size="small" effect="dark">待处理</el-tag>
|
||||
<el-tag v-if="row.zhuangtai === 3" type="warning" size="small" effect="dark">不满意</el-tag>
|
||||
<el-tag v-if="(row.shengji_cishu || 0) > 0" type="danger" size="small" effect="plain">
|
||||
第{{ (row.shengji_cishu || 0) + 1 }}次投诉
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="leixing_label" label="类型" width="100" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.zhuangtai)" size="small" effect="dark">
|
||||
{{ row.zhuangtai_label || statusLabel(row.zhuangtai) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投诉次数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': (row.shengji_cishu || 0) > 0 }">
|
||||
{{ (row.shengji_cishu || 0) + 1 }}次
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请人" width="130">
|
||||
<template #default="{ row }">
|
||||
<div class="id-cell">
|
||||
<span>{{ row.shenqingren_id || '-' }}</span>
|
||||
<el-button
|
||||
v-if="row.shenqingren_id"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="copyText(row.shenqingren_id)"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联单号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.order_id" class="id-cell">
|
||||
<span class="muted">订单</span>
|
||||
<span class="mono">{{ row.order_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.order_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.chongzhi_id" class="id-cell">
|
||||
<span class="muted">充值</span>
|
||||
<span class="mono">{{ row.chongzhi_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.chongzhi_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.fadan_id" class="id-cell">
|
||||
<span class="muted">罚单</span>
|
||||
<span class="mono">{{ row.fadan_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.fadan_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.guanshi_id" class="id-cell">
|
||||
<span class="muted">管事</span>
|
||||
<span class="mono">{{ row.guanshi_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.guanshi_id)">复制</el-button>
|
||||
</div>
|
||||
<span v-if="!row.order_id && !row.chongzhi_id && !row.fadan_id && !row.guanshi_id">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shuoming" label="说明" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="处理人" width="120">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.chuliren_name || row.chuliren_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click.stop="openDetail(row)">
|
||||
{{ isPending(row) || row.zhuangtai === 3 ? '去处理' : '详情' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawer"
|
||||
title="工单详情"
|
||||
size="520px"
|
||||
append-to-body
|
||||
class="gongdan-drawer"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="current">
|
||||
<div class="detail-wrap">
|
||||
<div class="detail-status-bar" :class="detailStatusClass(current)">
|
||||
<el-tag :type="statusTagType(current.zhuangtai)" effect="dark" size="large">
|
||||
{{ current.zhuangtai_label || statusLabel(current.zhuangtai) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(current.shengji_cishu || 0) > 0" type="danger" effect="plain" size="large">
|
||||
已补充投诉 {{ current.shengji_cishu }} 次 · 共 {{ (current.shengji_cishu || 0) + 1 }} 次
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="detail-row">
|
||||
<span class="label">工单号</span>
|
||||
<span class="value mono">{{ current.gongdan_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.gongdan_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">类型</span>
|
||||
<span class="value">{{ current.leixing_label }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">申请人</span>
|
||||
<span class="value">{{ current.shenqingren_id }}</span>
|
||||
<el-button
|
||||
v-if="current.shenqingren_id"
|
||||
type="primary"
|
||||
link
|
||||
@click="copyText(current.shenqingren_id)"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.order_id" class="detail-row">
|
||||
<span class="label">订单ID</span>
|
||||
<span class="value mono">{{ current.order_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.order_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.chongzhi_id" class="detail-row">
|
||||
<span class="label">充值ID</span>
|
||||
<span class="value mono">{{ current.chongzhi_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.chongzhi_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.fadan_id" class="detail-row">
|
||||
<span class="label">罚单ID</span>
|
||||
<span class="value mono">{{ current.fadan_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.fadan_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.guanshi_id" class="detail-row">
|
||||
<span class="label">管事ID</span>
|
||||
<span class="value mono">{{ current.guanshi_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.guanshi_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ formatTime(current.CreateTime) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">更新时间</span>
|
||||
<span class="value">{{ formatTime(current.UpdateTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">投诉说明</div>
|
||||
<div class="desc-box">{{ current.shuoming || '无' }}</div>
|
||||
<div v-if="current.images?.length" class="imgs">
|
||||
<el-image
|
||||
v-for="(img, i) in current.images"
|
||||
:key="i"
|
||||
:src="img.url"
|
||||
:preview-src-list="current.images.map(x => x.url)"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
style="width:88px;height:88px;margin:4px;border-radius:6px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="current.shengji_list?.length" class="detail-block escalate-block">
|
||||
<div class="block-title">二次 / 补充投诉记录</div>
|
||||
<div v-for="s in current.shengji_list" :key="s.cishu" class="shengji-item">
|
||||
<div class="shengji-head">
|
||||
<el-tag type="danger" size="small">第{{ s.cishu }}次补充</el-tag>
|
||||
<span class="time">{{ formatTime(s.CreateTime) }}</span>
|
||||
</div>
|
||||
<div class="shengji-body">{{ s.shuoming || '无说明' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">处理信息</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">处理人</span>
|
||||
<span class="value">{{ current.chuliren_name || current.chuliren_id || '尚未处理' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">处理时间</span>
|
||||
<span class="value">{{ formatTime(current.chuli_time) || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="current.chuli_jieguo" class="desc-box prev-result">
|
||||
<div class="muted-label">上次处理结果</div>
|
||||
{{ current.chuli_jieguo }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<div class="block-title">填写处理结果</div>
|
||||
<el-input v-model="jieguo" type="textarea" :rows="4" placeholder="填写处理结果(标记已处理时必填)" />
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" @click="handle('done')">标记已处理</el-button>
|
||||
<el-button :loading="saving" @click="handle('close')">关闭工单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const filters = reactive({ zhuangtai: '', leixing: '', keyword: '' })
|
||||
const onlyEscalated = ref(false)
|
||||
const needHandle = ref(false)
|
||||
const drawer = ref(false)
|
||||
const current = ref(null)
|
||||
const jieguo = ref('')
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
dissatisfied: 0,
|
||||
closed: 0,
|
||||
escalated: 0,
|
||||
need_handle: 0,
|
||||
})
|
||||
|
||||
function isPending(row) {
|
||||
return Number(row?.zhuangtai) === 1
|
||||
}
|
||||
|
||||
function statusLabel(z) {
|
||||
return ({ 1: '处理中', 2: '已处理', 3: '不满意', 4: '已关闭' })[Number(z)] || String(z || '')
|
||||
}
|
||||
|
||||
function statusTagType(z) {
|
||||
const n = Number(z)
|
||||
if (n === 1) return 'danger'
|
||||
if (n === 2) return 'success'
|
||||
if (n === 3) return 'warning'
|
||||
if (n === 4) return 'info'
|
||||
return ''
|
||||
}
|
||||
|
||||
function rowClassName({ row }) {
|
||||
if (Number(row.zhuangtai) === 1) return 'row-pending'
|
||||
if (Number(row.zhuangtai) === 3) return 'row-dissatisfied'
|
||||
if ((row.shengji_cishu || 0) > 0 && [1, 3].includes(Number(row.zhuangtai))) return 'row-escalate'
|
||||
return ''
|
||||
}
|
||||
|
||||
function detailStatusClass(row) {
|
||||
const z = Number(row?.zhuangtai)
|
||||
if (z === 1) return 'is-pending'
|
||||
if (z === 3) return 'is-dissatisfied'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTime(v) {
|
||||
if (!v) return ''
|
||||
return String(v).replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
const val = String(text || '').trim()
|
||||
if (!val) {
|
||||
ElMessage.warning('无可复制内容')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
ElMessage.success('已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function applyStats(data) {
|
||||
const s = data?.stats
|
||||
if (s && typeof s === 'object' && (s.total != null || s.pending != null || s.need_handle != null)) {
|
||||
stats.total = Number(s.total) || 0
|
||||
stats.pending = Number(s.pending) || 0
|
||||
stats.done = Number(s.done) || 0
|
||||
stats.dissatisfied = Number(s.dissatisfied) || 0
|
||||
stats.closed = Number(s.closed) || 0
|
||||
stats.escalated = Number(s.escalated) || 0
|
||||
stats.need_handle = Number(s.need_handle) || (stats.pending + stats.dissatisfied)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 旧后端无 stats 字段时:按状态各查一次 total 兜底 */
|
||||
async function loadStatsFallback() {
|
||||
const fetchTotal = async (payload = {}) => {
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/list/', {
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
...payload,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) return Number(res.data?.total) || 0
|
||||
} catch (e) {}
|
||||
return 0
|
||||
}
|
||||
const [pending, done, dissatisfied, closed, total, escalated] = await Promise.all([
|
||||
fetchTotal({ zhuangtai: 1 }),
|
||||
fetchTotal({ zhuangtai: 2 }),
|
||||
fetchTotal({ zhuangtai: 3 }),
|
||||
fetchTotal({ zhuangtai: 4 }),
|
||||
fetchTotal({}),
|
||||
fetchTotal({ only_escalated: 1 }),
|
||||
])
|
||||
stats.pending = pending
|
||||
stats.done = done
|
||||
stats.dissatisfied = dissatisfied
|
||||
stats.closed = closed
|
||||
stats.total = total
|
||||
stats.escalated = escalated
|
||||
stats.need_handle = pending + dissatisfied
|
||||
}
|
||||
|
||||
async function load(p = 1) {
|
||||
page.value = p
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/list/', {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
zhuangtai: needHandle.value || onlyEscalated.value ? '' : filters.zhuangtai,
|
||||
leixing: filters.leixing,
|
||||
keyword: filters.keyword,
|
||||
only_escalated: onlyEscalated.value ? 1 : 0,
|
||||
need_handle: needHandle.value ? 1 : 0,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
const ok = applyStats(res.data)
|
||||
if (!ok) await loadStatsFallback()
|
||||
} else {
|
||||
ElMessage.error(res.message || res.msg || '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
load(page.value)
|
||||
}
|
||||
|
||||
function search() {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
load(1)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.zhuangtai = ''
|
||||
filters.leixing = ''
|
||||
filters.keyword = ''
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
load(1)
|
||||
}
|
||||
|
||||
function quickFilter(zhuangtai) {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
filters.zhuangtai = zhuangtai
|
||||
load(1)
|
||||
}
|
||||
|
||||
function filterNeedHandle() {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = true
|
||||
filters.zhuangtai = ''
|
||||
load(1)
|
||||
}
|
||||
|
||||
function filterEscalated() {
|
||||
onlyEscalated.value = true
|
||||
needHandle.value = false
|
||||
filters.zhuangtai = ''
|
||||
load(1)
|
||||
}
|
||||
|
||||
async function openDetail(row) {
|
||||
drawer.value = true
|
||||
jieguo.value = row.chuli_jieguo || ''
|
||||
current.value = row
|
||||
try {
|
||||
const res = await request.post('/gongdan/detail/', { gongdan_id: row.gongdan_id })
|
||||
if (res.code === 200 || res.code === 0) current.value = res.data
|
||||
} catch (e) {
|
||||
// 保留列表行数据
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(action) {
|
||||
if (!current.value) return
|
||||
if (action === 'done' && !jieguo.value.trim()) {
|
||||
ElMessage.warning('请填写处理结果')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/handle/', {
|
||||
gongdan_id: current.value.gongdan_id,
|
||||
action,
|
||||
chuli_jieguo: jieguo.value,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('已保存')
|
||||
drawer.value = false
|
||||
load(page.value)
|
||||
} else ElMessage.error(res.message || res.msg || '失败')
|
||||
} catch (e) {
|
||||
ElMessage.error('处理失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gongdan-page { padding: 12px; color: #1f2937; }
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(6, 12, 20, 0.75);
|
||||
border: 1px solid rgba(0, 242, 255, 0.22);
|
||||
border-radius: 12px;
|
||||
padding: 16px 14px;
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 16px rgba(0, 242, 255, 0.18);
|
||||
border-color: rgba(0, 242, 255, 0.45);
|
||||
}
|
||||
.stat-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(238, 242, 255, 0.72);
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #eef2ff;
|
||||
}
|
||||
.stat-num .unit {
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(238, 242, 255, 0.55);
|
||||
}
|
||||
.stat-card.danger {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
background: linear-gradient(180deg, rgba(127, 29, 29, 0.45) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.danger .stat-num { color: #fca5a5; }
|
||||
.stat-card.warn {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: linear-gradient(180deg, rgba(120, 53, 15, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.warn .stat-num { color: #fcd34d; }
|
||||
.stat-card.escalate {
|
||||
border-color: rgba(251, 113, 133, 0.55);
|
||||
background: linear-gradient(180deg, rgba(136, 19, 55, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.escalate .stat-num { color: #fda4af; }
|
||||
.stat-card.need {
|
||||
border-color: rgba(248, 113, 113, 0.65);
|
||||
background: linear-gradient(180deg, rgba(153, 27, 27, 0.5) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.need .stat-num { color: #fecaca; }
|
||||
.stat-card.ok .stat-num { color: #86efac; }
|
||||
.stat-card.muted .stat-num { color: #93c5fd; }
|
||||
|
||||
.filter-card { margin-bottom: 12px; }
|
||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.actions { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.id-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.muted { color: #9ca3af; font-size: 12px; margin-right: 2px; }
|
||||
.text-danger { color: #dc2626; font-weight: 700; }
|
||||
|
||||
:deep(.row-pending) > td {
|
||||
background: #fef2f2 !important;
|
||||
}
|
||||
:deep(.row-dissatisfied) > td {
|
||||
background: #fffbeb !important;
|
||||
}
|
||||
:deep(.row-escalate) > td {
|
||||
background: #fff1f2 !important;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-status-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.detail-status-bar.is-pending { background: #fef2f2; }
|
||||
.detail-status-bar.is-dissatisfied { background: #fffbeb; }
|
||||
.detail-block {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.escalate-block {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
.block-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: #111827;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.detail-row .label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
.detail-row .value {
|
||||
flex: 1;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
}
|
||||
.desc-box {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #111827;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.prev-result { margin-top: 8px; }
|
||||
.muted-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.shengji-item {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed #fecaca;
|
||||
}
|
||||
.shengji-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.shengji-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.shengji-head .time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.shengji-body {
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stat-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* append-to-body 的抽屉不受 scoped 影响,单独保证可读性 */
|
||||
.gongdan-drawer.el-drawer,
|
||||
.gongdan-drawer .el-drawer__body,
|
||||
.gongdan-drawer .el-drawer__header {
|
||||
color: #1f2937 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
.gongdan-drawer .el-drawer__title {
|
||||
color: #111827 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user