chore: 保留后台当前版本(工单菜单、支付通道配置、聊天相关改动)

UFO 适配开工前的检查点,便于回滚。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-22 17:24:33 +08:00
parent f41145bc0d
commit 69fe2a51e0
7 changed files with 1497 additions and 142 deletions

View File

@@ -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; }

View File

@@ -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' }
},
// ========== 商品管理(原有) ==========
{

View File

@@ -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',

View File

@@ -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)

View File

@@ -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>

View File

@@ -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
View 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>