backup: kefu state before examiner-add feature on ExaminerManage

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-06-28 00:31:26 +08:00
parent a7f7bd0c39
commit b161077400
53 changed files with 6254 additions and 567 deletions

View File

@@ -19,27 +19,27 @@
<!-- 板块与财务 -->
<el-sub-menu index="/finance">
<el-sub-menu v-if="canShow('finance')" index="/finance">
<template #title>
<el-icon><DataAnalysis /></el-icon>
<span>板块与财务</span>
</template>
<el-menu-item index="/finance/bankuai">板块配置</el-menu-item>
<el-menu-item index="/finance/data">财务数据</el-menu-item>
<el-menu-item v-if="canShow('finance.bankuai') && isAllClubScope" index="/finance/bankuai">板块配置</el-menu-item>
<el-menu-item v-if="canShow('finance.data')" index="/finance/data">财务数据</el-menu-item>
</el-sub-menu>
<!-- 考核管理 -->
<el-sub-menu index="/assessment">
<el-sub-menu v-if="canShow('assessment')" index="/assessment">
<template #title>
<el-icon><Medal /></el-icon>
<span>考核管理</span>
</template>
<el-menu-item index="/assessment/examiner">考核官管理</el-menu-item>
<el-menu-item index="/assessment/config">考核配置</el-menu-item>
<el-menu-item index="/assessment/record">考核记录</el-menu-item>
<el-menu-item v-if="canShow('assessment.examiner')" index="/assessment/examiner">考核官管理</el-menu-item>
<el-menu-item v-if="canShow('assessment.config') && isAllClubScope" index="/assessment/config">考核配置</el-menu-item>
<el-menu-item v-if="canShow('assessment.record')" index="/assessment/record">考核记录</el-menu-item>
</el-sub-menu>
@@ -47,109 +47,119 @@
<!-- 订单管理 -->
<el-sub-menu index="/order">
<el-sub-menu v-if="canShow('order')" index="/order">
<template #title>
<el-icon><List /></el-icon>
<span>订单管理</span>
</template>
<el-menu-item index="/order/platform">平台订单</el-menu-item>
<el-menu-item index="/order/merchant">商家订单</el-menu-item>
<el-menu-item v-if="canShow('order.platform')" index="/order/platform">平台订单</el-menu-item>
<el-menu-item v-if="canShow('order.merchant')" index="/order/merchant">商家订单</el-menu-item>
</el-sub-menu>
<!-- 跨平台管理 -->
<el-sub-menu index="/cross-platform">
<el-sub-menu v-if="canShow('cross-platform')" index="/cross-platform">
<template #title>
<el-icon><Connection /></el-icon>
<span>跨平台管理</span>
</template>
<el-menu-item index="/cross-order">跨平台订单</el-menu-item>
<el-menu-item index="/cross-settings">跨平台设置</el-menu-item>
<el-menu-item index="/cross-data">跨平台数据</el-menu-item>
<el-menu-item index="/cross-pre">预结算管理</el-menu-item>
<el-menu-item v-if="canShow('cross-platform.order')" index="/cross-order">跨平台订单</el-menu-item>
<el-menu-item v-if="canShow('cross-platform.settings')" index="/cross-settings">跨平台设置</el-menu-item>
<el-menu-item v-if="canShow('cross-platform.data')" index="/cross-data">跨平台数据</el-menu-item>
<el-menu-item v-if="canShow('cross-platform.pre')" index="/cross-pre">预结算管理</el-menu-item>
</el-sub-menu>
<!-- 用户管理 -->
<el-sub-menu index="/user">
<el-sub-menu v-if="canShow('user')" index="/user">
<template #title>
<el-icon><User /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item index="/user/player">打手管理</el-menu-item>
<el-menu-item index="/user/guanli">管事管理</el-menu-item>
<el-menu-item index="/user/shangjia">商家管理</el-menu-item>
<el-menu-item index="/user/zuzhang">组长管理</el-menu-item>
<el-menu-item v-if="canShow('user.player')" index="/user/player">打手管理</el-menu-item>
<el-menu-item v-if="canShow('user.guanli')" index="/user/guanli">管事管理</el-menu-item>
<el-menu-item v-if="canShow('user.shangjia')" index="/user/shangjia">商家管理</el-menu-item>
<el-menu-item v-if="canShow('user.zuzhang')" index="/user/zuzhang">组长管理</el-menu-item>
<el-menu-item v-if="canShow('user.identity-tag')" index="/user/identity-tag">身份装饰标签</el-menu-item>
</el-sub-menu>
<!-- 提现管理 -->
<el-sub-menu index="/withdraw">
<el-sub-menu v-if="canShow('withdraw')" index="/withdraw">
<template #title>
<el-icon><Money /></el-icon>
<span>提现管理</span>
</template>
<el-menu-item index="/withdraw/audit">提现审核</el-menu-item>
<el-menu-item index="/withdraw/data">提现数据</el-menu-item>
<el-menu-item index="/withdraw/settings">提现设置</el-menu-item>
<el-menu-item v-if="canShow('withdraw.audit')" index="/withdraw/audit">提现审核</el-menu-item>
<el-menu-item v-if="canShow('withdraw.data')" index="/withdraw/data">提现数据</el-menu-item>
<el-menu-item v-if="canShow('withdraw.settings')" index="/withdraw/settings">提现设置</el-menu-item>
</el-sub-menu>
<!-- 管理员管理 -->
<el-sub-menu index="/admin">
<el-sub-menu v-if="canShow('admin')" index="/admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>管理员管理</span>
</template>
<el-menu-item index="/admin/role">角色管理</el-menu-item>
<el-menu-item index="/admin/user">后台用户</el-menu-item>
<el-menu-item v-if="canShow('admin.role')" index="/admin/role">角色管理</el-menu-item>
<el-menu-item v-if="canShow('admin.data-scope')" index="/admin/data-scope">数据范围配置</el-menu-item>
<el-menu-item v-if="canShow('admin.club-config')" index="/admin/club-config">俱乐部密钥配置</el-menu-item>
<el-menu-item v-if="canShow('admin.user')" index="/admin/user">后台用户</el-menu-item>
<el-menu-item v-if="canShow('admin.operation-log')" index="/admin/operation-log">操作日志</el-menu-item>
</el-sub-menu>
<!-- 处罚管理 -->
<el-menu-item index="/punishment">
<el-menu-item v-if="canShow('punishment')" index="/punishment">
<el-icon><Warning /></el-icon>
<span>处罚管理</span>
</el-menu-item>
<!-- 商品管理 -->
<el-sub-menu index="/product">
<el-sub-menu v-if="canShow('product')" index="/product">
<template #title>
<el-icon><Goods /></el-icon>
<span>商品管理</span>
</template>
<el-menu-item index="/product/list">商品列表</el-menu-item>
<el-menu-item index="/product/type-zone">商品类型专区管理</el-menu-item>
<el-menu-item index="/product/data">商品数据分析</el-menu-item>
<el-menu-item v-if="canShow('product.list') && isAllClubScope" index="/product/list">商品列表</el-menu-item>
<el-menu-item v-if="canShow('product.type-zone') && isAllClubScope" index="/product/type-zone">商品类型专区管理</el-menu-item>
<el-menu-item v-if="canShow('product.data') && isAllClubScope" index="/product/data">商品数据分析</el-menu-item>
</el-sub-menu>
<!-- 会员管理 -->
<el-sub-menu index="/member">
<el-sub-menu v-if="canShow('member')" index="/member">
<template #title>
<el-icon><UserFilled /></el-icon>
<span>会员管理</span>
</template>
<el-menu-item index="/member/list">会员管理</el-menu-item>
<el-menu-item index="/member/data">会员数据分析</el-menu-item>
<el-menu-item v-if="canShow('member.list')" index="/member/list">会员管理</el-menu-item>
<el-menu-item v-if="canShow('member.data')" index="/member/data">会员数据分析</el-menu-item>
</el-sub-menu>
<!-- 小程序配置 -->
<el-sub-menu index="/miniapp">
<el-sub-menu v-if="canShow('miniapp')" index="/miniapp">
<template #title>
<el-icon><Setting /></el-icon>
<span>小程序配置</span>
</template>
<el-menu-item index="/miniapp/popup-notice">弹窗公告管理</el-menu-item>
<el-menu-item v-if="canShow('miniapp.carousel')" index="/miniapp/carousel">轮播图与公告</el-menu-item>
<el-menu-item v-if="canShow('miniapp.carousel') || canShow('miniapp.assets')" index="/miniapp/assets">图标与频道</el-menu-item>
<el-menu-item v-if="canShow('miniapp.popup')" index="/miniapp/popup-notice">弹窗公告管理</el-menu-item>
<el-menu-item v-if="canShow('miniapp.leixing')" index="/miniapp/leixing">抢单商品类型</el-menu-item>
<el-menu-item v-if="canShow('miniapp.kaohe-tags')" index="/miniapp/kaohe-tags">抢单考核标签</el-menu-item>
<el-menu-item v-if="canShow('miniapp.dashou-exam')" index="/miniapp/dashou-exam">打手接单考试</el-menu-item>
<el-menu-item v-if="canShow('miniapp.withdraw-settings')" index="/withdraw/settings">提现与收款设置</el-menu-item>
</el-sub-menu>
<!-- 店铺管理 -->
<el-sub-menu index="/shop">
<el-sub-menu v-if="canShow('shop')" index="/shop">
<template #title>
<el-icon><Shop /></el-icon>
<span>店铺管理</span>
</template>
<el-menu-item index="/shop/list">店铺列表</el-menu-item>
<el-menu-item index="/shop/withdraw-apply">店铺提现申请</el-menu-item>
<el-menu-item index="/shop/products">店铺商品管理</el-menu-item>
<el-menu-item v-if="canShow('shop.list')" index="/shop/list">店铺列表</el-menu-item>
<el-menu-item v-if="canShow('shop.withdraw')" index="/shop/withdraw-apply">店铺提现申请</el-menu-item>
<el-menu-item v-if="canShow('shop.products')" index="/shop/products">店铺商品管理</el-menu-item>
</el-sub-menu>
<!-- 会话管理客服专用 -->
<el-menu-item index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
<el-menu-item v-if="canShow('chat.agent')" index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
<el-icon><Comment /></el-icon>
<span>会话管理</span>
<el-badge
@@ -170,21 +180,46 @@
<el-container>
<el-header class="header">
<div class="header-left">
<span class="page-title">{{ currentTitle }}</span>
</div>
<div class="stats-mini">
<div v-for="stat in statsList" :key="stat.label" class="stat-mini-card">
<div class="stat-mini-icon">{{ stat.icon }}</div>
<div class="stat-mini-content">
<div class="stat-mini-label">{{ stat.label }}</div>
<div class="stat-mini-value">{{ stat.value }}</div>
</div>
<div class="header-row header-row-main">
<div class="header-left">
<span class="page-title">{{ currentTitle }}</span>
<el-select
v-if="showScopeSelector"
v-model="scopeSelectValue"
size="small"
class="club-select"
@change="onScopeSelectChange"
>
<el-option
v-if="canGroupScope"
label="集团汇总(全部子公司)"
value="__all__"
/>
<el-option
v-for="item in clubOptions"
:key="item.club_id"
:label="item.name"
:value="item.club_id"
/>
</el-select>
<span v-else-if="scopeBadgeText" class="club-badge">{{ scopeBadgeText }}</span>
<span v-if="adminRoleName" class="role-badge">{{ adminRoleName }}</span>
</div>
<div class="header-right">
<span class="user-badge">{{ userPhone }}</span>
<el-button class="logout-btn" size="small" @click="handleLogout" plain>退出</el-button>
</div>
</div>
<div class="header-right">
<span class="user-badge">{{ userPhone }}</span>
<el-button class="logout-btn" size="small" @click="handleLogout" plain>退出</el-button>
<div class="header-row header-row-stats">
<div class="stats-mini">
<div v-for="stat in statsList" :key="stat.label" class="stat-mini-card">
<div class="stat-mini-icon">{{ stat.icon }}</div>
<div class="stat-mini-content">
<div class="stat-mini-label">{{ stat.label }}</div>
<div class="stat-mini-value">{{ stat.value }}</div>
</div>
</div>
</div>
</div>
</el-header>
@@ -196,18 +231,43 @@
</router-view>
</el-main>
</el-container>
<KefuConnect v-if="hasAnyChatPerm" />
</el-container>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted, getCurrentInstance, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElNotification } from 'element-plus'
import {
List, User, Warning, Money, Fold, Expand, Connection, Setting,
Goods, UserFilled, Shop, Comment,DataAnalysis, Medal // 新增
} from '@element-plus/icons-vue'
import request from '@/utils/request'
import KefuConnect from '@/components/KefuConnect.vue'
import { disconnectKefuChat } from '@/utils/kefuChat'
import {
getAdminClubContext,
getAdminClubId,
getAdminClubScope,
setAdminClubContext,
mergeServerClubContext,
JITUAN_API,
setMenuAccess,
getMenuAccess,
canSwitchClubByMenu,
shouldShowAllMenus,
ensureMenuAccess,
} from '@/utils/club-context'
const menuAccessState = ref(getMenuAccess())
const canShow = (pageId) => {
const access = menuAccessState.value ?? getMenuAccess()
if (shouldShowAllMenus(access)) return true
if (access.visible_page_ids.length === 0) return false
return access.visible_page_ids.includes(pageId)
}
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -221,6 +281,90 @@ const toggleCollapse = () => {
const currentTitle = computed(() => route.meta?.title || '客服终端')
const userPhone = ref(localStorage.getItem('username') || '未知账号')
const clubOptions = ref([])
const scopeSelectValue = ref('')
const adminCtx = computed(() => getAdminClubContext())
const isGroupAdmin = computed(() => !!adminCtx.value?.is_group_admin)
const canGroupScope = computed(() => isGroupAdmin.value)
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const isSingleClubScope = computed(() => getAdminClubScope() !== 'all')
const showScopeSelector = computed(() => canSwitchClubByMenu())
const scopeBadgeText = computed(() => {
const scope = getAdminClubScope()
if (scope === 'all') return '集团汇总(全部子公司)'
const cid = getAdminClubId()
const hit = clubOptions.value.find((c) => c.club_id === cid)
return hit ? `${hit.name}${cid}` : cid
})
const adminRoleName = computed(() => adminCtx.value?.role_name || '')
const syncScopeSelectValue = () => {
const scope = getAdminClubScope()
scopeSelectValue.value = scope === 'all' ? '__all__' : getAdminClubId()
}
const loadMenuAccess = async () => {
try {
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess)
return res.code === 0 ? res.data : null
})
const data = getMenuAccess()
if (data) {
menuAccessState.value = data
const ctx = getAdminClubContext()
if (ctx) {
setAdminClubContext({ ...ctx, can_switch_club: data.can_switch_club })
}
}
} catch (error) {
console.error('加载菜单权限失败:', error)
}
}
const loadClubContext = async () => {
try {
const res = await request.get(JITUAN_API.meContext)
if (res.code === 0 && res.data) {
mergeServerClubContext(res.data)
clubOptions.value = res.data.clubs || []
syncScopeSelectValue()
return
}
} catch (error) {
console.error('加载俱乐部上下文失败:', error)
}
const ctx = getAdminClubContext()
if (ctx && ctx.clubs) {
clubOptions.value = ctx.clubs
syncScopeSelectValue()
}
}
const onScopeSelectChange = (val) => {
const ctx = getAdminClubContext() || {}
if (val === '__all__') {
setAdminClubContext({
...ctx,
scope: 'ALL_CLUBS',
club_id: ctx.club_id || 'xq',
})
ElMessage.success('已切换至:集团汇总(全部子公司)')
} else {
setAdminClubContext({
...ctx,
scope: 'SINGLE_CLUB',
club_id: val,
})
const hit = clubOptions.value.find((c) => c.club_id === val)
ElMessage.success(`已切换至:${hit?.name || val}`)
}
window.location.reload()
}
const stats = ref({
jinrichuli: 0,
jinyuechuli: 0,
@@ -256,7 +400,9 @@ const fetchStats = async () => {
}
const handleLogout = () => {
disconnectKefuChat()
localStorage.clear()
sessionStorage.clear()
router.push('/login')
ElMessage.success('已退出登录')
}
@@ -286,63 +432,86 @@ const fetchKefuPermission = async () => {
const { permissions, goeasy_appkey } = res.data
kefuPermissions.value = permissions || []
goeasyAppkey.value = goeasy_appkey || ''
// 如果有权限且有appkey初始化GoEasy仅一次
if (hasAnyChatPerm.value && goeasyAppkey.value) {
initGoEasy()
}
}
} catch (error) {
console.error('获取客服权限失败:', error)
}
}
// 初始化GoEasy实例并连接
const initGoEasy = () => {
// 防止重复初始化
if (window.goeasy) return
// 1. 初始化实例
window.goeasy = GoEasy.getInstance({
host: 'hangzhou.goeasy.io', // 根据实际区域调整
appkey: goeasyAppkey.value,
modules: ['im']
})
// 2. 构建客服自己的userId = KF + 账号
const agentId = 'KF' + userPhone.value
const agentData = {
name: userPhone.value, // 客服名称
avatar: '' // 可后续加上头像
}
// 3. 建立连接
window.goeasy.connect({
id: agentId,
data: agentData,
onSuccess: () => {
console.log('客服GoEasy连接成功')
// 连接成功后通知Agent页面通过provide
proxy.$emitter.emit('goeasy-connected')
},
onFailed: (error) => {
console.error('GoEasy连接失败', error)
ElMessage.error('消息服务连接失败,请刷新重试')
},
onProgress: (attempts) => {
console.log('GoEasy正在重连第' + attempts + '次')
}
})
// 4. 保存客服标识到全局
window.__kefuAgentId = agentId
}
// 监听子组件传来的未读消息更新
const onAgentUnread = (count) => {
agentUnread.value = count
}
let lastUnreadForNotify = 0
let unreadBaselineReady = false
const playNotifySound = () => {
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext
if (!AudioCtx) return
const ctx = new AudioCtx()
const playBeep = (freq, start, duration) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = freq
gain.gain.value = 0.25
osc.start(start)
osc.stop(start + duration)
}
playBeep(880, ctx.currentTime, 0.12)
playBeep(1100, ctx.currentTime + 0.15, 0.12)
setTimeout(() => ctx.close(), 500)
} catch (e) {
// ignore
}
}
const showMessageNotification = (title, message) => {
playNotifySound()
ElNotification({
title,
message,
type: 'info',
duration: 0,
showClose: true,
position: 'top-right',
customClass: 'kefu-msg-notify',
onClick: () => {
ElNotification.closeAll()
router.push('/chat/agent')
}
})
}
const onMessageNotify = (payload) => {
if (route.path === '/chat/agent') return
const customerId = payload?.customerId || '用户'
const preview = payload?.preview || '收到新消息'
showMessageNotification('新会话消息', `${customerId}: ${preview}`)
}
const onUnreadBumpNotify = (count) => {
if (!unreadBaselineReady) {
lastUnreadForNotify = count
unreadBaselineReady = true
return
}
if (route.path === '/chat/agent') {
lastUnreadForNotify = count
return
}
if (count <= 0) {
lastUnreadForNotify = 0
return
}
if (count > lastUnreadForNotify) {
showMessageNotification('会话未读提醒', `您有 ${count} 条未读消息,点击查看`)
}
lastUnreadForNotify = count
}
// 向子组件提供客服相关数据
provide('kefuPermissions', kefuPermissions)
provide('permPrefixMap', permPrefixMap)
@@ -350,16 +519,22 @@ provide('goeasyAppkey', goeasyAppkey)
provide('hasAnyChatPerm', hasAnyChatPerm)
onMounted(() => {
loadMenuAccess()
loadClubContext()
fetchStats()
fetchKefuPermission() // 登录后先获取权限
fetchKefuPermission()
proxy.$emitter?.on('refresh-stats', fetchStats)
proxy.$emitter?.on('agent-unread', onAgentUnread)
proxy.$emitter?.on('agent-unread', onUnreadBumpNotify)
proxy.$emitter?.on('kefu-message-notify', onMessageNotify)
})
onUnmounted(() => {
if (abortController) abortController.abort()
proxy.$emitter?.off('refresh-stats', fetchStats)
proxy.$emitter?.off('agent-unread', onAgentUnread)
proxy.$emitter?.off('agent-unread', onUnreadBumpNotify)
proxy.$emitter?.off('kefu-message-notify', onMessageNotify)
})
</script>
@@ -501,19 +676,67 @@ onUnmounted(() => {
background: rgba(6, 12, 20, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 242, 255, 0.3);
display: flex;
flex-direction: column;
padding: 10px 24px 12px;
gap: 10px;
height: auto;
min-height: 80px;
}
.header-row {
display: flex;
align-items: center;
width: 100%;
}
.header-row-main {
justify-content: space-between;
padding: 0 24px;
height: 80px;
gap: 20px;
flex-wrap: wrap;
gap: 16px;
}
.header-row-stats {
justify-content: center;
}
.header-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
}
.club-select {
width: 200px;
flex-shrink: 0;
}
.club-badge {
white-space: nowrap;
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
color: rgba(0, 242, 255, 0.9);
border: 1px solid rgba(0, 242, 255, 0.35);
background: rgba(0, 242, 255, 0.08);
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
.role-badge {
white-space: nowrap;
flex-shrink: 0;
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
color: rgba(0, 242, 255, 0.85);
border: 1px solid rgba(0, 242, 255, 0.35);
background: rgba(0, 242, 255, 0.08);
}
.page-title {
font-size: 1.4rem;
font-weight: 700;
@@ -529,9 +752,10 @@ onUnmounted(() => {
.stats-mini {
display: flex;
gap: 16px;
flex-wrap: wrap;
flex: 1;
flex-wrap: nowrap;
justify-content: center;
overflow-x: auto;
max-width: 100%;
}
.stat-mini-card {
@@ -640,15 +864,13 @@ onUnmounted(() => {
/* 响应式 */
@media screen and (max-width: 1024px) {
.header {
height: auto;
padding: 12px 20px;
padding: 10px 16px;
}
.header-row-main {
flex-wrap: wrap;
}
.stats-mini {
order: 3;
width: 100%;
justify-content: flex-start;
margin-top: 8px;
}
.stat-mini-card {
padding: 6px 16px;
@@ -681,4 +903,22 @@ onUnmounted(() => {
.agent-badge {
margin-left: 4px;
}
</style>
<style>
.kefu-msg-notify {
min-width: 320px;
border-left: 4px solid #409eff !important;
box-shadow: 0 4px 20px rgba(64, 158, 255, 0.35) !important;
}
.kefu-msg-notify .el-notification__title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.kefu-msg-notify .el-notification__content {
font-size: 14px;
color: #606266;
line-height: 1.5;
}
</style>