From 69fe2a51e0909eb74d6dabfe811a8b06a9c0b6c3 Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 22 Jul 2026 17:24:33 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BF=9D=E7=95=99=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=89=88=E6=9C=AC=EF=BC=88=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E3=80=81=E6=94=AF=E4=BB=98=E9=80=9A=E9=81=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E8=81=8A=E5=A4=A9=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UFO 适配开工前的检查点,便于回滚。 Co-authored-by: Cursor --- src/components/ChatDialog.vue | 253 ++++++++---- src/router/index.js | 5 + src/utils/club-context.js | 23 +- src/utils/kefuChat.js | 231 ++++++++++- src/views/admin/ClubConfig.vue | 334 +++++++++++++-- src/views/chat/Agent.vue | 64 ++- src/views/gongdan/List.vue | 729 +++++++++++++++++++++++++++++++++ 7 files changed, 1497 insertions(+), 142 deletions(-) create mode 100644 src/views/gongdan/List.vue diff --git a/src/components/ChatDialog.vue b/src/components/ChatDialog.vue index c12f25b..85a389a 100644 --- a/src/components/ChatDialog.vue +++ b/src/components/ChatDialog.vue @@ -10,7 +10,10 @@ >
- 客服对话 + + 客服对话 + → {{ safeCustomerId }} + 刷新历史 @@ -33,17 +36,22 @@
-
📋 订单号:{{ msg.payload?.dingdan_id }}
+
订单号:{{ msg.payload?.dingdan_id }}
金额:¥{{ msg.payload?.jine }}
[{{ msg.type || '未知' }}] {{ msg.payload?.text || '' }}
-
- +
+ + 点击重发
发送中... @@ -52,9 +60,21 @@
- +
@@ -63,14 +83,16 @@ @@ -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; } diff --git a/src/router/index.js b/src/router/index.js index be75892..d2d5e52 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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' } + }, // ========== 商品管理(原有) ========== { diff --git a/src/utils/club-context.js b/src/utils/club-context.js index 8d848c2..5897450 100644 --- a/src/utils/club-context.js +++ b/src/utils/club-context.js @@ -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', diff --git a/src/utils/kefuChat.js b/src/utils/kefuChat.js index cfb0b2b..fe44ad9 100644 --- a/src/utils/kefuChat.js +++ b/src/utils/kefuChat.js @@ -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) diff --git a/src/views/admin/ClubConfig.vue b/src/views/admin/ClubConfig.vue index 311b346..a5f2def 100644 --- a/src/views/admin/ClubConfig.vue +++ b/src/views/admin/ClubConfig.vue @@ -5,7 +5,7 @@ :closable="false" show-icon title="俱乐部主数据与小程序/支付/GoEasy 密钥" - description="仅超级管理员可编辑。修改后影响该俱乐部小程序登录、支付回调、IM 等,请谨慎操作。" + description="仅超级管理员可编辑。收款商户与提现商户可分开配置;证书支持直接上传,无需再手填服务器路径。" class="tip" /> @@ -41,7 +41,7 @@ - + 小程序微信 @@ -50,31 +50,153 @@ - 微信支付(同一商户号,V2 收款 / V3 提现 密钥分开配) - - + + 收款商户(点单 / 会员 / 押金 · V2) + + + - + -
微信商户平台 → API安全 → 设置APIv2密钥。与下方 APIv3 不是同一个。
- + +
+ + + 上传 + +
+
+ +
+ + + 上传 + +
+
+ + + 提现商户(转账到零钱 · V3,可与收款不同) + + +
与收款商户不同时必填;相同可留空。
+
+ -
微信商户平台 → API安全 → 设置APIv3密钥。仅用于提现,不用于小程序收款。
+ +
+ + + 上传 + +
+
+ +
+ + + 上传 + +
+
+ +
+ + + 上传 + +
+
回调验签用;存为提现目录下的 pub_key.pem,字段记录目录。
+
+ + + + + + 小程序收款通道(微信直连 / 付呗) + + + + 微信直连商户号 + 付呗 + +
保存俱乐部配置时会一并写入;也可点下方「仅切换通道」立即生效。
+
+ + + + + + + + + + + + + + + + + 保存付呗配置 + 仅切换通道 + + - - - - - - - - - 服务号 @@ -131,7 +244,7 @@ + + + +