48 Commits

Author SHA1 Message Date
XingQue
52bf301ded feat: 集团后台订单互通抢单配置页
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 03:08:10 +08:00
XingQue
5aa3b05b44 fix: 会员卡片上传成功后同步列表预览路径
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:52:53 +08:00
XingQue
cacae6cfc7 fix: 超级管理000001本地兜底显示后台用户/角色管理菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:21:19 +08:00
XingQue
db386a0953 fix: 本店超级管理可显示添加用户并管理本俱乐部角色
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:15:03 +08:00
XingQue
975e37411e fix: 图标上传不再手动设置 multipart Content-Type
避免缺少 boundary 导致后端读不到 icon_key;失败时展示具体 key。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 20:56:04 +08:00
XingQue
ee9c3c2381 feat: 接单池卡背景与充值页大背景始终显示上传入口
未上传显示空占位与「上传背景」;会员/保证金页大图单独成组,不依赖接口是否已下发新 key。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 20:52:14 +08:00
XingQue
a57992786c fix: 轮播管理强制展示点单第二横幅与个人中心背景选项
合并本地兜底 page_options,避免旧接口列表盖掉 UFO 两项;补充操作提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:13:27 +08:00
XingQue
33def56c31 feat: 转盘后台改为商品/奖池/控奖,履约商品下拉选择
去掉手填购票商品 ID,运营按名称价格创建转盘商品并配置奖池与控奖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 20:40:03 +08:00
XingQue
772d142a40 feat: 转盘活动页与路由放行,轮播增加点单背景/横幅选项
修复侧栏可见但点转盘被踢到板块配置的问题;Carousel 增加 boss_center 与 accept_order_banner2。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 20:21:55 +08:00
XingQue
69fe2a51e0 chore: 保留后台当前版本(工单菜单、支付通道配置、聊天相关改动)
UFO 适配开工前的检查点,便于回滚。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:24:33 +08:00
XingQue
f41145bc0d fix: 后台展示投诉记录与我的处罚分项图标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 04:15:49 +08:00
XingQue
87121ffebc fix: 后台图标页展示点单端服务图标与关注快手
按俱乐部独立配置;补「点单端我的」分区,打手区增加关注快手入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 04:06:34 +08:00
XingQue
7c51b1f38b fix: 类型专区页支持俱乐部上架下架;商品数据分析仅集团可见
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:33:55 +08:00
XingQue
23f7263996 fix: 商品管理菜单在具体俱乐部可见,支持专区与商品管理
侧栏不再要求集团汇总才显示商品列表/类型专区;类型CRUD仍仅集团汇总可写。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:24:44 +08:00
XingQue
c5bb036b68 fix: 点单端推荐官海报文案,强调独立上传背景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:48:18 +08:00
XingQue
e6f6447c30 feat: 后台增加点单端海报位与只看审核开关
小程序资源页展示 cuser_poster_bg;店铺商品页增加只看审核商品开关。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:37:32 +08:00
XingQue
6e7e775c55 feat: 打手详情赠送会员 UI,并改善后台错误提示
新增赠送会员送/改期/移除;请求失败优先展示后端 msg;考核官添加防重复提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 02:26:09 +08:00
XingQue
fa6b456630 feat: 俱乐部配置页展示 APIv2 mch_key 与 APIv3 api_v3_key 及配置警告
与后端 club-manage 对齐,区分小程序收款密钥与提现转账密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:34:32 +08:00
XingQue
f1ba100d56 feat: 店铺管理页新增复制店铺商品功能
在店铺详情中支持填写源/目标用户ID并选择覆盖或补充模式调用新接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 22:58:57 +08:00
XingQue
5eae3507b0 fix: 会员数据分析支持履约失败/已取消状态筛选
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:07:55 +08:00
XingQue
d3dc775bd3 fix: 订单类型请求去掉 phone,区分 JWT 401 与业务错误
kfhqptddlx 只带 JWT;响应拦截器仅在 token 失效时清登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:35:24 +08:00
XingQue
75173553a1 fix: 消除welcome路由死循环,商家订单进页刷新权限 2026-07-06 19:28:36 +08:00
XingQue
ab91d9a363 fix: 商家订单权限与后端一致(002ac/002ab/003aa) 2026-07-06 19:25:15 +08:00
XingQue
0c08243a37 fix: 菜单与API权限码双重校验,切换俱乐部后不再展示无权限页面 2026-07-06 19:21:38 +08:00
XingQue
1c57cea841 fix: 后台用户按俱乐部分配角色,菜单与多俱乐部权限对齐 2026-07-06 19:14:01 +08:00
XingQue
bb540ced45 fix: 侧栏仅展示服务端visible_page_ids,无权限不显示商家订单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:03:24 +08:00
XingQue
ac1fafc817 fix: 切换俱乐部后侧栏菜单响应式刷新
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:00:19 +08:00
XingQue
4b20c1fe39 fix: 商家订单以API为准,菜单与permission_codes对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:44:12 +08:00
XingQue
41e263dee7 fix: 菜单严格按服务端visible_paths,切换俱乐部强制刷新
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:40:43 +08:00
XingQue
f7ec5e63e0 fix: 商家订单页权限校验与菜单刷新
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:36:40 +08:00
XingQue
b05cb984a5 fix: 菜单缓存版本14配合后端严格权限
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:32:03 +08:00
XingQue
5b89e1b3c8 fix: 无权限时禁止展示全部菜单与路由
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:28:25 +08:00
XingQue
1a5c8aab88 fix: 管理员用户页按权限隐藏跨俱乐部操作
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:23:29 +08:00
XingQue
9b11749903 fix: 角色管理请求携带 target_club_id
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:17:38 +08:00
XingQue
f3a6fa47bc fix: 管理员详情深色UI与按俱乐部分组展示角色
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:06:38 +08:00
XingQue
58be204448 fix: 管理员用户详情弹窗崩溃与多俱乐部分配UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:00:00 +08:00
XingQue
37a1a15f90 fix: 商品管理菜单显示假单管理入口
补全 club-context 菜单白名单与父级映射;侧栏不再限制仅俱乐部视图才显示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:32:37 +08:00
XingQue
21f56619b6 feat: 假单管理后台页面(商品管理权限)
商品管理菜单下新增假单 CRUD 页,按俱乐部配置各商品类型假单,支持头像上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:28:14 +08:00
XingQue
4e2e7604c3 fix: 会员数据表格主行文字改为白色提高可读性
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:36:56 +08:00
XingQue
414c005f93 fix: 会员数据分析展开行高对比度可读性
展开分红明细改为深底白字/蓝标签/绿金额,并覆盖 el-table 展开单元格默认灰底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:36:33 +08:00
XingQue
fa81909065 fix: 会员数据分析页深色主题与默认每页10条
统一输入框/下拉/日期/分页为深色样式,去掉表格白条纹;默认分页改为10条。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:33:39 +08:00
XingQue
288d001579 feat: 会员数据分析页与俱乐部新建总会员入口
实现会员充值记录筛选表格与汇总;俱乐部视图增加新建总会员按钮并对接 hy-bundle-create、hy-recharge-records 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:27:51 +08:00
XingQue
16b04e3905 feat: 会员管理支持总会员创建、上架包含子会员与装饰图上传
- 集团新建时可勾选总会员
- 俱乐部上架/编辑时多选包含子会员
- 装饰图上传提示与编辑模式说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:12:59 +08:00
XingQue
021d00e932 feat: 打手中心充值横幅与全页图标后台分组配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:36:32 +08:00
XingQue
6ed29939cb feat: 排行榜奖励后台配置页
- 小程序配置下新增排行榜奖励菜单与 RankReward 页面

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:40:56 +08:00
XingQue
1c64f84d68 chore: 客服聊天配置与排行榜奖励开发前备份
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:30:23 +08:00
XingQue
12506c5d32 fix: login landing from menu_access, silent optional Layout 403
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:59:33 +08:00
XingQue
9e7dba32d2 fix: 俱乐部超管不可见集团级管理员菜单项
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:52:24 +08:00
38 changed files with 6582 additions and 507 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

@@ -3,9 +3,13 @@ import Layout from '@/views/Layout.vue'
import request from '@/utils/request'
import {
ensureMenuAccess,
syncClubContextFromServer,
getMenuAccess,
getFirstVisiblePath,
getDefaultLandingPath,
getEffectiveVisiblePaths,
isLocallyDefinedMenuPathAllowed,
isMenuSessionFresh,
hasMerchantOrderPerm,
JITUAN_API,
} from '@/utils/club-context'
@@ -17,10 +21,13 @@ const routes = [
{
path: '/',
component: Layout,
redirect: () => getFirstVisiblePath() || '/order/platform',
redirect: () => getDefaultLandingPath() || '/welcome',
children: [
{
path: 'welcome',
component: () => import('@/views/Welcome.vue'),
meta: { title: '首页' }
},
// ========== 板块与财务 ==========
{
path: 'finance/bankuai',
@@ -166,6 +173,11 @@ const routes = [
component: () => import('@/views/admin/ClubConfig.vue'),
meta: { title: '俱乐部密钥配置' }
},
{
path: 'admin/order-grab-share',
component: () => import('@/views/admin/OrderGrabShare.vue'),
meta: { title: '订单互通抢单' }
},
{
path: 'admin/data-scope',
component: () => import('@/views/admin/DataScope.vue'),
@@ -207,6 +219,11 @@ const routes = [
component: () => import('@/views/punishment/Detail.vue'),
meta: { title: '处罚详情' }
},
{
path: 'gongdan',
component: () => import('@/views/gongdan/List.vue'),
meta: { title: '投诉工单', perm: 'gdck666' }
},
// ========== 商品管理(原有) ==========
{
@@ -219,11 +236,21 @@ const routes = [
component: () => import('@/views/product/TypeZone.vue'),
meta: { title: '商品类型专区管理' }
},
{
path: 'product/turntable',
component: () => import('@/views/product/Turntable.vue'),
meta: { title: '转盘活动' }
},
{
path: 'product/data',
component: () => import('@/views/product/Data.vue'),
meta: { title: '商品数据分析' }
},
{
path: 'product/fake-orders',
component: () => import('@/views/product/FakeGrabOrderManage.vue'),
meta: { title: '假单管理' }
},
{
path: 'product/detail/:id',
component: () => import('@/views/product/Detail.vue'),
@@ -271,6 +298,16 @@ const routes = [
component: () => import('@/views/miniapp/DashouExam.vue'),
meta: { title: '打手接单考试' }
},
{
path: 'miniapp/rank-reward',
component: () => import('@/views/miniapp/RankReward.vue'),
meta: { title: '排行榜奖励' }
},
{
path: 'miniapp/recharge-copy',
component: () => import('@/views/club/RechargePageCopy.vue'),
meta: { title: '充值页文案' }
},
// ========== 店铺管理(原有) ==========
{
path: 'shop/list',
@@ -292,7 +329,12 @@ const routes = [
{
path: 'chat/agent',
component: () => import('@/views/chat/Agent.vue'),
meta: { title: '会话管理' }
meta: { title: '会话接入' }
},
{
path: 'chat/script-config',
component: () => import('@/views/chat/ScriptConfig.vue'),
meta: { title: '客服聊天配置' }
}
]
}
@@ -304,8 +346,12 @@ const router = createRouter({
})
/** 详情页等子路由:父模块在 visible_paths 中即允许访问 */
function isRouteAllowed(path, visiblePaths) {
if (!visiblePaths?.length) return true
function isRouteAllowed(path, access) {
const visiblePaths = getEffectiveVisiblePaths(access)
if (!visiblePaths?.length) return false
if (path === '/order/merchant' || path.startsWith('/order/merchant/')) {
if (!hasMerchantOrderPerm(access)) return false
}
if (visiblePaths.some((p) => p && (path === p || path.startsWith(`${p}/`)))) {
return true
}
@@ -328,6 +374,10 @@ function isRouteAllowed(path, visiblePaths) {
return visiblePaths.some((p) => parents.includes(p))
}
}
// 新菜单仅写在前端时:侧栏能显示则允许进入,避免被踢到板块配置等默认页
if (isLocallyDefinedMenuPathAllowed(path)) {
return true
}
return false
}
@@ -343,27 +393,53 @@ router.beforeEach(async (to, from, next) => {
}
try {
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess)
await syncClubContextFromServer(async () => {
const res = await request.get(JITUAN_API.meContext, { skipErrorToast: true })
return res.code === 0 ? res.data : null
})
const needFreshMenu = !isMenuSessionFresh() || !getMenuAccess()
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess, { skipErrorToast: true })
return res.code === 0 ? res.data : null
}, { force: needFreshMenu })
} catch (e) {
console.error('路由加载菜单权限失败:', e)
}
const access = getMenuAccess()
if (!isMenuSessionFresh() || !access?.visible_paths?.length) {
if (to.path === '/welcome') {
next()
return
}
if (!access || !isMenuSessionFresh()) {
if (to.path !== '/welcome') {
next('/welcome')
return
}
next()
return
}
if (!getEffectiveVisiblePaths(access).length) {
if (to.path !== '/welcome') {
next('/welcome')
return
}
next()
return
}
const path = to.path
if (!isRouteAllowed(path, access.visible_paths)) {
const fallback = getFirstVisiblePath()
if (fallback && path !== fallback) {
next(fallback)
if (!isRouteAllowed(path, access)) {
const fallback = getDefaultLandingPath(access) || '/welcome'
if (fallback === path) {
next('/welcome')
return
}
next(fallback)
return
}
next()
})

View File

@@ -3,7 +3,7 @@ const CLUB_SCOPE_KEY = 'admin_club_scope';
const CLUB_CONTEXT_KEY = 'admin_club_context';
const MENU_ACCESS_KEY = 'kefu_menu_access';
const MENU_SESSION_KEY = 'kefu_menu_session_v';
const MENU_SESSION_VERSION = '7';
const MENU_SESSION_VERSION = '27';
const DEFAULT_CLUB_ID = 'xq';
let menuAccessInflight = null;
@@ -25,27 +25,55 @@ export function setAdminClubContext(ctx) {
export function mergeServerClubContext(serverCtx) {
if (!serverCtx) return;
const allowedIds = new Set((serverCtx.clubs || []).map((c) => c.club_id));
const prevScope = localStorage.getItem(CLUB_SCOPE_KEY);
const prevClubId = localStorage.getItem(CLUB_ID_KEY);
const merged = { ...serverCtx };
if (prevScope) {
merged.scope = prevScope === 'all' ? 'ALL_CLUBS' : 'SINGLE_CLUB';
merged.club_id = prevClubId || merged.club_id || DEFAULT_CLUB_ID;
let clubId = serverCtx.club_id || DEFAULT_CLUB_ID;
let scopeKey = serverCtx.scope === 'ALL_CLUBS' ? 'all' : 'single';
if (prevClubId && allowedIds.has(prevClubId)) {
clubId = prevClubId;
if (prevScope === 'all' && serverCtx.is_group_admin) {
scopeKey = 'all';
} else if (prevScope === 'single') {
scopeKey = 'single';
}
} else if (prevScope === 'all' && serverCtx.is_group_admin) {
scopeKey = 'all';
} else if (!allowedIds.has(clubId) && allowedIds.size > 0) {
clubId = serverCtx.club_id || [...allowedIds][0] || DEFAULT_CLUB_ID;
}
localStorage.setItem(CLUB_CONTEXT_KEY, JSON.stringify(merged));
if (prevClubId) {
localStorage.setItem(CLUB_ID_KEY, prevClubId);
} else {
localStorage.setItem(CLUB_ID_KEY, merged.club_id || DEFAULT_CLUB_ID);
const merged = {
...serverCtx,
club_id: clubId,
scope: scopeKey === 'all' ? 'ALL_CLUBS' : 'SINGLE_CLUB',
};
if (scopeKey === 'single' && merged.assignments?.length) {
const hit = merged.assignments.find((a) => a.club_id === clubId);
if (hit) {
merged.role_code = hit.role_code;
merged.role_name = hit.role_name;
}
}
if (prevScope) {
localStorage.setItem(CLUB_SCOPE_KEY, prevScope);
} else {
localStorage.setItem(CLUB_SCOPE_KEY, merged.scope === 'ALL_CLUBS' ? 'all' : 'single');
if (prevClubId && prevClubId !== clubId) {
clearMenuAccess();
}
setAdminClubContext(merged);
return merged;
}
export function resetAdminClubContextOnLogin() {
localStorage.removeItem(CLUB_ID_KEY);
localStorage.removeItem(CLUB_SCOPE_KEY);
localStorage.removeItem(CLUB_CONTEXT_KEY);
clearMenuAccess();
}
export function getAdminClubContext() {
try {
const raw = localStorage.getItem(CLUB_CONTEXT_KEY);
@@ -80,48 +108,258 @@ export function isMenuSessionFresh() {
}
export function shouldShowAllMenus(access) {
if (!isMenuSessionFresh()) return true;
if (!access) return true;
if (access.menu_ready === false) return true;
if (!Array.isArray(access.visible_page_ids) || !Array.isArray(access.visible_paths)) return true;
if (!access) return false;
if (Array.isArray(access.visible_page_ids) && access.visible_page_ids.length > 0) {
return false;
}
if (Array.isArray(access.visible_paths) && access.visible_paths.length > 0) {
return false;
}
if (Array.isArray(access.permission_codes) && access.permission_codes.length > 0) {
return false;
}
return false;
}
/** ??? kefu_menu_definitions ???visible_paths ???? permission_codes ?? */
const KEFU_MENU_ROWS = [
{ page_id: 'finance.bankuai', parent_id: 'finance', path: '/finance/bankuai', sort_order: 11, perm_codes: ['bankuai'] },
{ page_id: 'finance.data', parent_id: 'finance', path: '/finance/data', sort_order: 12, perm_codes: ['caiwu'] },
{ page_id: 'assessment.examiner', parent_id: 'assessment', path: '/assessment/examiner', sort_order: 21, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.config', parent_id: 'assessment', path: '/assessment/config', sort_order: 22, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.record', parent_id: 'assessment', path: '/assessment/record', sort_order: 23, perm_codes: ['kaohepeizhi'] },
{ page_id: 'order.platform', parent_id: 'order', path: '/order/platform', sort_order: 31, perm_codes: ['002ab'] },
{ page_id: 'order.merchant', parent_id: 'order', path: '/order/merchant', sort_order: 32, perm_codes: ['002ac', '002ab', '003aa'] },
{ page_id: 'cross-platform.order', parent_id: 'cross-platform', path: '/cross-order', sort_order: 41, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.settings', parent_id: 'cross-platform', path: '/cross-settings', sort_order: 42, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.data', parent_id: 'cross-platform', path: '/cross-data', sort_order: 43, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.pre', parent_id: 'cross-platform', path: '/cross-pre', sort_order: 44, perm_codes: ['003aa'] },
{ page_id: 'user.player', parent_id: 'user', path: '/user/player', sort_order: 51, perm_codes: ['001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg'] },
{ page_id: 'user.guanli', parent_id: 'user', path: '/user/guanli', sort_order: 52, perm_codes: ['4400a', '4400b', '4400c', '4400d', '4400e', '4400f'] },
{ page_id: 'user.shangjia', parent_id: 'user', path: '/user/shangjia', sort_order: 53, perm_codes: ['1100a', '1100b'] },
{ page_id: 'user.zuzhang', parent_id: 'user', path: '/user/zuzhang', sort_order: 54, perm_codes: ['6600a', '6600b', '6600c', '6600d', '6600e'] },
{ page_id: 'user.identity-tag', parent_id: 'user', path: '/user/identity-tag', sort_order: 55, perm_codes: ['8080a', 'sfbq666'] },
{ page_id: 'withdraw.audit', parent_id: 'withdraw', path: '/withdraw/audit', sort_order: 61, perm_codes: ['005bb'] },
{ 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'] },
// 管理员:超级管理 000001 可见(不靠 perm_codes
{ page_id: 'admin.role', parent_id: 'admin', path: '/admin/role', sort_order: 71, require_super: true },
{ page_id: 'admin.user', parent_id: 'admin', path: '/admin/user', sort_order: 74, require_super: true },
{ page_id: 'admin.operation-log', parent_id: 'admin', path: '/admin/operation-log', sort_order: 75, perm_codes: ['czrz666'] },
{ 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.turntable', parent_id: 'product', path: '/product/turntable', sort_order: 93, perm_codes: ['2200a'] },
{ page_id: 'product.data', parent_id: 'product', path: '/product/data', sort_order: 94, perm_codes: ['2200a'] },
{ page_id: 'product.fake-orders', parent_id: 'product', path: '/product/fake-orders', sort_order: 95, perm_codes: ['2200a'] },
{ page_id: 'member.list', parent_id: 'member', path: '/member/list', sort_order: 101, perm_codes: ['3300a'] },
{ page_id: 'member.data', parent_id: 'member', path: '/member/data', sort_order: 102, perm_codes: ['3300a'] },
{ page_id: 'miniapp.carousel', parent_id: 'miniapp', path: '/miniapp/carousel', sort_order: 111, perm_codes: ['8080a'] },
{ page_id: 'miniapp.assets', parent_id: 'miniapp', path: '/miniapp/assets', sort_order: 112, perm_codes: ['8080a'] },
{ page_id: 'miniapp.popup', parent_id: 'miniapp', path: '/miniapp/popup-notice', sort_order: 113, perm_codes: ['8080a'] },
{ page_id: 'miniapp.leixing', parent_id: 'miniapp', path: '/miniapp/leixing', sort_order: 115, perm_codes: ['8080a'] },
{ page_id: 'miniapp.kaohe-tags', parent_id: 'miniapp', path: '/miniapp/kaohe-tags', sort_order: 116, perm_codes: ['8080a'] },
{ page_id: 'miniapp.dashou-exam', parent_id: 'miniapp', path: '/miniapp/dashou-exam', sort_order: 117, perm_codes: ['dsks666', '8080a'] },
{ page_id: 'miniapp.rank-reward', parent_id: 'miniapp', path: '/miniapp/rank-reward', sort_order: 118, perm_codes: ['phbj666', '8080a'] },
{ page_id: 'miniapp.recharge-copy', parent_id: 'miniapp', path: '/miniapp/recharge-copy', sort_order: 119, perm_codes: ['8080a'] },
{ page_id: 'miniapp.withdraw-settings', parent_id: 'miniapp', path: '/withdraw/settings', sort_order: 114, perm_codes: ['5500a', '5500b', '5500c'] },
{ page_id: 'shop.list', parent_id: 'shop', path: '/shop/list', sort_order: 121, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'shop.withdraw', parent_id: 'shop', path: '/shop/withdraw-apply', sort_order: 122, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'shop.products', parent_id: 'shop', path: '/shop/products', sort_order: 123, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'chat.agent', parent_id: '', path: '/chat/agent', sort_order: 130, perm_codes: ['abca1', 'baac2', 'cabc3', 'cb3a2', 'bcaa4'] },
];
const KEFU_MENU_PARENT_IDS = {
admin: ['admin.role', 'admin.data-scope', 'admin.club-config', 'admin.order-grab-share', 'admin.user', 'admin.operation-log'],
finance: ['finance.bankuai', 'finance.data'],
assessment: ['assessment.examiner', 'assessment.config', 'assessment.record'],
order: ['order.platform', 'order.merchant'],
'cross-platform': ['cross-platform.order', 'cross-platform.settings', 'cross-platform.data', 'cross-platform.pre'],
user: ['user.player', 'user.guanli', 'user.shangjia', 'user.zuzhang', 'user.identity-tag'],
withdraw: ['withdraw.audit', 'withdraw.data', 'withdraw.settings'],
product: ['product.list', 'product.type-zone', 'product.turntable', 'product.data', 'product.fake-orders'],
member: ['member.list', 'member.data'],
miniapp: ['miniapp.carousel', 'miniapp.assets', 'miniapp.popup', 'miniapp.leixing', 'miniapp.kaohe-tags', 'miniapp.dashou-exam', 'miniapp.rank-reward', 'miniapp.recharge-copy', 'miniapp.withdraw-settings'],
shop: ['shop.list', 'shop.withdraw', 'shop.products'],
};
function pathsFromPermissionCodes(codes) {
if (!codes?.length) return [];
const seen = new Set();
const paths = [];
for (const row of KEFU_MENU_ROWS) {
if (!row.path) continue;
const perms = row.perm_codes || [];
if (!perms.some((p) => codes.includes(p))) continue;
if (seen.has(row.path)) continue;
seen.add(row.path);
paths.push(row.path);
}
return paths;
}
function pageAllowedByCodes(pageId, codes) {
const row = KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
if (row?.perm_codes?.some((p) => codes.includes(p))) return true;
const children = KEFU_MENU_PARENT_IDS[pageId];
if (!children) return false;
return children.some((childId) => pageAllowedByCodes(childId, codes));
}
export function getEffectiveVisiblePaths(access = null) {
const a = access || getMenuAccess();
const paths = a?.visible_paths;
return Array.isArray(paths) ? paths : [];
}
/**
* 兼容尚未 seed 到服务端的新菜单:本地已定义且 canShowMenuPage 通过即可进路由。
* 避免「侧栏看得见、点进去却被踢到默认页(如板块配置)」。
*/
export function isLocallyDefinedMenuPathAllowed(path) {
if (!path) return false;
const row = KEFU_MENU_ROWS.find(
(r) => r.path && (path === r.path || path.startsWith(`${r.path}/`)),
);
if (!row) return false;
return canShowMenuPage(row.page_id);
}
export function hasPermCode(access, ...codes) {
if (!access || !codes.length) return false;
const list = access.permission_codes || [];
return codes.some((c) => list.includes(c));
}
export function hasMerchantOrderPerm(access) {
return hasPermCode(access, '002ac', '002ab', '003aa');
}
export function canShowMenuPage(pageId) {
const access = getMenuAccess();
if (shouldShowAllMenus(access)) return true;
if (access.visible_page_ids.length === 0) return false;
return access.visible_page_ids.includes(pageId);
if (!access) return false;
const codes = access.permission_codes || [];
const hasSuper = !!access.is_super || codes.includes('000001');
// 本店超级管理:管理员分组 / 角色管理 / 后台用户(不依赖服务端是否漏 seed
if (
hasSuper &&
(pageId === 'admin' || pageId === 'admin.role' || pageId === 'admin.user')
) {
return true;
}
// 集团权限:订单互通配置(菜单 seed 未跑时本地兜底)
if (pageId === 'admin.order-grab-share' && access.is_group_admin) {
return true;
}
const ids = access?.visible_page_ids;
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 || hasSuper) {
if (row.require_super) return true;
}
return pageAllowedByCodes(pageId, codes);
}
if (access.is_super || access.is_group_admin || hasSuper) {
const row = KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
if (row?.require_super && hasSuper) return true;
return !!KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
}
return pageAllowedByCodes(pageId, codes);
}
/** 写入菜单缓存,并同步服务端校正后的 effective_club_id */
export function applyMenuAccessPayload(data) {
if (!data) return null;
const scope = getAdminClubScope();
const expectedClub = getAdminClubId();
if (
data.effective_club_id &&
scope !== 'all' &&
data.effective_club_id !== expectedClub
) {
const ctx = getAdminClubContext() || {};
setAdminClubContext({
...ctx,
club_id: data.effective_club_id,
scope: 'SINGLE_CLUB',
});
}
setMenuAccess(data);
return data;
}
export function getFirstVisiblePath() {
const access = getMenuAccess();
if (!access || !Array.isArray(access.visible_paths) || access.visible_paths.length === 0) {
return null;
const paths = getEffectiveVisiblePaths();
return paths.length > 0 ? paths[0] : null;
}
export function getDefaultLandingPath(access = null) {
const paths = getEffectiveVisiblePaths(access);
return paths.length > 0 ? paths[0] : null;
}
/** menu_ready=false: server seed missing; empty codes: role not bound */
export function formatNoMenuAccessMessage(access) {
const codes = access?.permission_codes || [];
const roles = access?.role_names || [];
const uid = access?.yonghuid || '';
const idHint = uid ? ` UserID=${uid}.` : '';
if (access?.menu_ready === false && access?.menu_source === 'db') {
return `Menu table empty.${idHint} Server: python manage.py migrate jituan && python manage.py seed_kefu_menu`;
}
return access.visible_paths[0];
if (!codes.length) {
if (roles.length) {
return `No permissions.${idHint} Roles: [${roles.join(', ')}]. Add perm kaohepeizhi to these roles in Role Mgmt.`;
}
return `No role on account.${idHint} Admin Users -> open THIS user (check UserID) -> Add Role -> Save -> re-login.`;
}
if (!getEffectiveVisiblePaths(access).length) {
return `Perms: [${codes.join(', ')}].${idHint} No menu path from server.`;
}
return `Perms: [${codes.join(', ')}].${idHint} Contact admin.`;
}
export function canSwitchClubByMenu() {
const ctx = getAdminClubContext();
if (ctx?.can_switch_club) return true;
const access = getMenuAccess();
if (!access || !isMenuSessionFresh()) {
const ctx = getAdminClubContext();
return !!ctx?.can_switch_club;
}
return !!access.can_switch_club;
return !!access?.can_switch_club;
}
export async function ensureMenuAccess(fetcher) {
export async function syncClubContextFromServer(fetcher) {
const prevClubId = getAdminClubId();
try {
const data = await fetcher();
if (data) {
mergeServerClubContext(data);
if (getAdminClubId() !== prevClubId) {
clearMenuAccess();
}
}
} catch (e) {
console.error('syncClubContextFromServer failed:', e);
}
}
export async function ensureMenuAccess(fetcher, { force = false } = {}) {
if (!localStorage.getItem('token')) return getMenuAccess();
if (isMenuSessionFresh() && getMenuAccess()) {
if (!force && isMenuSessionFresh() && getMenuAccess()) {
return getMenuAccess();
}
if (!menuAccessInflight) {
menuAccessInflight = (async () => {
try {
const data = await fetcher();
if (data) setMenuAccess(data);
return data;
if (data) return applyMenuAccessPayload(data);
return getMenuAccess();
} catch (e) {
console.error('ensureMenuAccess failed:', e);
return getMenuAccess();
@@ -148,6 +386,7 @@ export const JITUAN_API = {
kefuLogin: '/jituan/auth/kefu-login',
meContext: '/jituan/auth/me-context',
menuAccess: '/jituan/auth/menu-access',
permDebug: '/jituan/auth/perm-debug',
clubList: '/jituan/club/list',
caiwu: '/jituan/houtai/caiwu',
szxx: '/jituan/houtai/szxx',
@@ -161,6 +400,7 @@ export const JITUAN_API = {
dashouList: '/jituan/houtai/kefuhqdslb',
guanshiList: '/jituan/houtai/hthqgslb',
shangjiaList: '/jituan/houtai/hqsjgllb',
shangjiaAdd: '/jituan/houtai/tjsj',
zuzhangList: '/jituan/houtai/hthqzzlb',
operationLog: '/jituan/houtai/hqczrz',
withdrawGet: '/jituan/houtai/hthqtxpz',
@@ -168,6 +408,8 @@ export const JITUAN_API = {
lunboList: '/jituan/houtai/lunbo-list',
lunboManage: '/jituan/houtai/lunbo-manage',
gonggao: '/jituan/houtai/gonggao',
rechargePageCopyGet: '/jituan/houtai/recharge-page-copy/get',
rechargePageCopySave: '/jituan/houtai/recharge-page-copy/save',
tupianList: '/jituan/houtai/tupian-list',
tupianManage: '/jituan/houtai/tupian-manage',
popupList: '/jituan/houtai/hthqtcxx',
@@ -175,16 +417,23 @@ export const JITUAN_API = {
penaltyStats: '/jituan/houtai/htglyhqcfsltj',
penaltyList: '/jituan/houtai/hthqfklb',
penaltyAction: '/jituan/houtai/glyclfk',
penaltyPlatformAudit: '/jituan/houtai/glyptshfk',
penaltyCreate: '/jituan/houtai/htfksc',
kefuCfgl: '/jituan/houtai/kefu-cfgl',
adminAssignments: '/jituan/houtai/admin-assignments',
clubManage: '/jituan/houtai/club-manage',
orderGrabLink: '/jituan/houtai/order-grab-link',
paymentChannelManage: '/jituan/houtai/payment-channel-manage',
clubCertUpload: '/jituan/houtai/club-cert-upload',
memberList: '/jituan/houtai/hthqhylb',
memberUpdate: '/jituan/houtai/htxghyxx',
memberAdd: '/jituan/houtai/httjhy',
memberCatalog: '/jituan/houtai/hthycatalog',
memberEnable: '/jituan/houtai/hthysj',
memberDisable: '/jituan/houtai/hthyxj',
memberCardUpload: '/jituan/houtai/hy-card-upload',
memberRechargeRecords: '/jituan/houtai/hy-recharge-records',
memberBundleCreate: '/jituan/houtai/hy-bundle-create',
identityTagList: '/jituan/houtai/identity-tag/list',
identityTagManage: '/jituan/houtai/identity-tag/manage',
identityTagBindList: '/jituan/houtai/identity-tag/bind-list',
@@ -192,6 +441,9 @@ export const JITUAN_API = {
miniappIconList: '/jituan/houtai/miniapp-icon-list',
miniappIconManage: '/jituan/houtai/miniapp-icon-manage',
pindaoManage: '/jituan/houtai/pindao-manage',
fakeGrabOrderList: '/jituan/houtai/fake-grab-order-list',
fakeGrabOrderManage: '/jituan/houtai/fake-grab-order-manage',
fakeGrabOrderAvatar: '/jituan/houtai/fake-grab-order-avatar',
clubLeixingList: '/jituan/houtai/club-leixing-list',
clubLeixingCatalog: '/jituan/houtai/club-leixing-catalog',
clubLeixingEnable: '/jituan/houtai/club-leixing-enable',

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

@@ -0,0 +1,30 @@
/** 金额罚单状态(与 Django utils/penalty_status.py 一致) */
export const PENALTY_STATUS_MAP = {
1: { text: '待缴纳', type: 'danger' },
2: { text: '已缴纳', type: 'success' },
3: { text: '申诉中', type: 'warning' },
4: { text: '已驳回', type: 'info' },
5: { text: '平台审核中', type: 'warning' },
}
export const penaltyStatusText = (code) => PENALTY_STATUS_MAP[code]?.text || '未知'
export const penaltyStatusType = (code) => PENALTY_STATUS_MAP[code]?.type || ''
/** 列表行展示状态(优先用后端 zhuangtai_text */
export const rowPenaltyStatusText = (row) => {
if (row?.zhuangtai_text) return row.zhuangtai_text
if (row?.platform_audit_pending) return '平台审核中'
return penaltyStatusText(Number(row?.zhuangtai))
}
export const rowPenaltyStatusType = (row) => {
if (row?.platform_audit_pending) return 'warning'
return penaltyStatusType(Number(row?.zhuangtai))
}
/** 是否可平台审核(仅 zhuangtai=5 */
export const isPlatformAuditPending = (row) => {
if (!row) return false
if (row.platform_audit_pending === true) return true
return Number(row.zhuangtai) === 5
}

View File

@@ -35,13 +35,25 @@ service.interceptors.response.use(
if (error.response) {
const status = error.response.status
if (status === 401) {
localStorage.removeItem('token')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
const data = error.response.data
const isJwtAuthFailure =
!data ||
data.detail !== undefined ||
data.code === 'token_not_valid' ||
data.code === 'authentication_failed'
if (isJwtAuthFailure && !error.config?.skipAuthRedirect) {
localStorage.removeItem('token')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (!error.config?.skipErrorToast) {
ElMessage.error(data?.msg || '认证失败')
}
} else if (status === 403) {
ElMessage.error('没有权限')
if (!error.config?.skipErrorToast) {
ElMessage.error('没有权限')
}
} else {
ElMessage.error(error.response.data?.message || '请求失败')
ElMessage.error(error.response.data?.msg || error.response.data?.message || '请求失败')
}
} else {
ElMessage.error('网络错误')

View File

@@ -99,8 +99,9 @@
<span>管理员管理</span>
</template>
<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.data-scope') && isGroupAdmin" index="/admin/data-scope">数据范围配置</el-menu-item>
<el-menu-item v-if="canShow('admin.club-config') && isGroupAdmin" index="/admin/club-config">俱乐部密钥配置</el-menu-item>
<el-menu-item v-if="canShow('admin.order-grab-share') && isGroupAdmin" index="/admin/order-grab-share">订单互通抢单</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>
@@ -110,6 +111,10 @@
<el-icon><Warning /></el-icon>
<span>处罚管理</span>
</el-menu-item>
<el-menu-item v-if="canShow('gongdan')" index="/gongdan">
<el-icon><ChatDotRound /></el-icon>
<span>投诉工单</span>
</el-menu-item>
<!-- 商品管理 -->
<el-sub-menu v-if="canShow('product')" index="/product">
@@ -117,9 +122,11 @@
<el-icon><Goods /></el-icon>
<span>商品管理</span>
</template>
<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.list')" index="/product/list">商品列表</el-menu-item>
<el-menu-item v-if="canShow('product.type-zone')" index="/product/type-zone">商品类型专区管理</el-menu-item>
<el-menu-item v-if="canShow('product.turntable')" index="/product/turntable">转盘活动</el-menu-item>
<el-menu-item v-if="canShow('product.data') && isAllClubScope" index="/product/data">商品数据分析</el-menu-item>
<el-menu-item v-if="canShow('product.fake-orders')" index="/product/fake-orders">假单管理</el-menu-item>
</el-sub-menu>
<!-- 会员管理 -->
@@ -144,6 +151,8 @@
<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.rank-reward')" index="/miniapp/rank-reward">排行榜奖励</el-menu-item>
<el-menu-item v-if="canShow('miniapp.recharge-copy')" index="/miniapp/recharge-copy">充值页文案</el-menu-item>
<el-menu-item v-if="canShow('miniapp.withdraw-settings')" index="/withdraw/settings">提现与收款设置</el-menu-item>
</el-sub-menu>
@@ -158,17 +167,25 @@
<el-menu-item v-if="canShow('shop.products')" index="/shop/products">店铺商品管理</el-menu-item>
</el-sub-menu>
<!-- 会话管理客服专用 -->
<el-menu-item v-if="canShow('chat.agent')" index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
<el-icon><Comment /></el-icon>
<span>会话管理</span>
<el-badge
v-if="hasAnyChatPerm && agentUnread > 0"
:value="agentUnread"
class="agent-badge"
style="margin-left: 8px;"
/>
</el-menu-item>
<!-- 会话管理 -->
<el-sub-menu v-if="canShow('chat')" index="/chat">
<template #title>
<el-icon><Comment /></el-icon>
<span>会话管理</span>
<el-badge
v-if="hasAnyChatPerm && agentUnread > 0"
:value="agentUnread"
class="agent-badge"
style="margin-left: 8px;"
/>
</template>
<el-menu-item v-if="canShow('chat.agent')" index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
会话接入
</el-menu-item>
<el-menu-item v-if="canShow('chat.script-config')" index="/chat/script-config">
客服聊天配置
</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="collapse-btn" @click="toggleCollapse">
@@ -241,7 +258,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElNotification } from 'element-plus'
import {
List, User, Warning, Money, Fold, Expand, Connection, Setting,
Goods, UserFilled, Shop, Comment,DataAnalysis, Medal // 新增
Goods, UserFilled, Shop, Comment, DataAnalysis, Medal, ChatDotRound
} from '@element-plus/icons-vue'
import request from '@/utils/request'
import KefuConnect from '@/components/KefuConnect.vue'
@@ -256,17 +273,16 @@ import {
setMenuAccess,
getMenuAccess,
canSwitchClubByMenu,
shouldShowAllMenus,
ensureMenuAccess,
canShowMenuPage,
clearMenuAccess,
} 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)
void menuAccessState.value
return canShowMenuPage(pageId)
}
const { proxy } = getCurrentInstance()
@@ -311,7 +327,7 @@ const loadMenuAccess = async () => {
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess)
return res.code === 0 ? res.data : null
})
}, { force: true })
const data = getMenuAccess()
if (data) {
menuAccessState.value = data
@@ -327,7 +343,7 @@ const loadMenuAccess = async () => {
const loadClubContext = async () => {
try {
const res = await request.get(JITUAN_API.meContext)
const res = await request.get(JITUAN_API.meContext, { skipErrorToast: true })
if (res.code === 0 && res.data) {
mergeServerClubContext(res.data)
clubOptions.value = res.data.clubs || []
@@ -354,14 +370,18 @@ const onScopeSelectChange = (val) => {
})
ElMessage.success('已切换至:集团汇总(全部子公司)')
} else {
const assign = (ctx.assignments || []).find((a) => a.club_id === val)
setAdminClubContext({
...ctx,
scope: 'SINGLE_CLUB',
club_id: val,
role_code: assign?.role_code || ctx.role_code,
role_name: assign?.role_name || ctx.role_name,
})
const hit = clubOptions.value.find((c) => c.club_id === val)
ElMessage.success(`已切换至:${hit?.name || val}`)
}
clearMenuAccess()
window.location.reload()
}
@@ -386,7 +406,7 @@ const fetchStats = async () => {
try {
const res = await request.post('/yonghu/kfjrhqzl', {
phone: userPhone.value
}, { signal: abortController.signal })
}, { signal: abortController.signal, skipErrorToast: true })
if (res.code === 0) {
stats.value = res.data
}
@@ -417,9 +437,9 @@ const agentUnread = ref(0) // 未读消息总数
const permPrefixMap = {
'abca1': 'Ds', // 打手
'baac2': 'Boss', // 老板
'cabc3': 'Gs', // 管事
'cabc3': 'Ds', // 管事(与小程序一致,统一 Ds
'cb3a2': 'Sj', // 商家
'bcaa4': 'Zz' // 组长
'bcaa4': 'Ds' // 组长(与小程序一致,统一 Ds
}
// 获取客服权限及GoEasy配置
@@ -427,7 +447,7 @@ const fetchKefuPermission = async () => {
try {
const res = await request.post('/houtai/kfhqltqx', {
phone: userPhone.value
})
}, { skipErrorToast: true })
if (res.code === 0) {
const { permissions, goeasy_appkey } = res.data
kefuPermissions.value = permissions || []
@@ -518,9 +538,9 @@ provide('permPrefixMap', permPrefixMap)
provide('goeasyAppkey', goeasyAppkey)
provide('hasAnyChatPerm', hasAnyChatPerm)
onMounted(() => {
loadMenuAccess()
loadClubContext()
onMounted(async () => {
await loadClubContext()
await loadMenuAccess()
fetchStats()
fetchKefuPermission()
proxy.$emitter?.on('refresh-stats', fetchStats)

View File

@@ -65,7 +65,7 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Key } from '@element-plus/icons-vue'
import axios from 'axios' // 直接使用 axios不经过封装的 request
import { JITUAN_API, setAdminClubContext, setMenuAccess } from '@/utils/club-context'
import { JITUAN_API, getDefaultLandingPath, setAdminClubContext, applyMenuAccessPayload, resetAdminClubContextOnLogin } from '@/utils/club-context'
const router = useRouter()
const loading = ref(false)
@@ -90,6 +90,7 @@ const handleLogin = async () => {
}
loading.value = true
resetAdminClubContextOnLogin()
try {
// 直接使用 axios 发送请求baseURL 从 window.$baseURL 获取
const response = await axios.post(
@@ -116,16 +117,18 @@ const handleLogin = async () => {
if (res.data.club_context) {
const ctx = { ...res.data.club_context }
if (res.data.menu_access) {
setMenuAccess(res.data.menu_access)
applyMenuAccessPayload(res.data.menu_access)
sessionStorage.setItem('kefu_menu_session_v', '24')
ctx.can_switch_club = res.data.menu_access.can_switch_club
}
setAdminClubContext(ctx)
} else if (res.data.menu_access) {
setMenuAccess(res.data.menu_access)
applyMenuAccessPayload(res.data.menu_access)
}
const ma = res.data.menu_access || {}
const landing = getDefaultLandingPath(ma)
ElMessage.success('登录成功')
const firstPath = res.data.menu_access?.visible_paths?.[0]
router.push(firstPath || '/order/platform')
router.push(landing || '/welcome')
} else if (res.code === 4) {
// 账号被封禁
ElMessage.error('账号已被封禁')

95
src/views/Welcome.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div class="welcome-page">
<el-result icon="warning" title="暂无可用功能菜单">
<template #sub-title>
<p v-if="yonghuid">用户ID{{ yonghuid }}</p>
<p v-if="clubLabel">当前俱乐部{{ clubLabel }}</p>
<p v-if="roleNames.length">本俱乐部已绑角色{{ roleNames.join('') }}</p>
<p v-else-if="permCodes.length">权限码{{ permCodes.join('') }}无对应菜单</p>
<p v-else>当前俱乐部下未绑定任何功能角色</p>
<p class="hint">{{ hintText }}</p>
</template>
<template #extra>
<el-button type="primary" @click="goTry">刷新权限</el-button>
<el-button @click="logout">退出登录</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import {
applyMenuAccessPayload,
clearMenuAccess,
getAdminClubContext,
getAdminClubId,
getDefaultLandingPath,
getMenuAccess,
JITUAN_API,
} from '@/utils/club-context'
const router = useRouter()
const access = computed(() => getMenuAccess() || {})
const yonghuid = computed(() => access.value.yonghuid || '')
const roleNames = computed(() => access.value.role_names || [])
const permCodes = computed(() => access.value.permission_codes || [])
const clubLabel = computed(() => {
const cid = access.value.effective_club_id || getAdminClubId()
const ctx = getAdminClubContext()
const hit = (ctx?.clubs || []).find((c) => c.club_id === cid)
return hit ? `${hit.name}${cid}` : cid
})
const hintText = computed(() => {
if (access.value.perm_hint) return access.value.perm_hint
return (
'数据范围(任职)与功能角色是两套配置:切换俱乐部后,须在该俱乐部单独绑定 gvsdsdk 角色。'
+ '请超管在「后台用户」→ 选择俱乐部 → 添加角色 → 重新登录。'
)
})
const goTry = async () => {
try {
clearMenuAccess()
const res = await request.get(JITUAN_API.menuAccess)
if (res.code === 0 && res.data) {
applyMenuAccessPayload(res.data)
const landing = getDefaultLandingPath(res.data)
if (landing) {
ElMessage.success('权限已生效')
router.replace(landing)
return
}
}
ElMessage.warning('仍无可用菜单,请确认已在当前俱乐部绑定功能角色')
} catch {
ElMessage.error('刷新失败')
}
}
const logout = () => {
localStorage.removeItem('token')
clearMenuAccess()
router.replace('/login')
}
onMounted(async () => {
// 不再自动跳转,避免与路由守卫形成 welcome ↔ 业务页 死循环
})
</script>
<style scoped>
.welcome-page {
padding: 48px 24px;
}
.hint {
margin-top: 8px;
color: #909399;
font-size: 14px;
line-height: 1.6;
max-width: 520px;
}
</style>

View File

@@ -35,15 +35,15 @@
<el-option label="全部角色" value="" />
<el-option
v-for="role in roleList"
:key="role.role_code"
:label="role.role_name"
:key="`${role.club_id || ''}-${role.role_code}`"
:label="roleClubLabel(role)"
:value="role.role_code"
/>
</el-select>
<el-button type="primary" @click="fetchUserList">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-button type="success" @click="openAddUserDialog">
<el-button v-if="canAddUser" type="success" @click="openAddUserDialog">
<el-icon><Plus /></el-icon> 添加用户
</el-button>
</div>
@@ -51,13 +51,33 @@
<!-- 用户表格 -->
<el-table :data="userList" v-loading="loading" border class="user-table">
<el-table-column prop="phone" label="账号" width="140" />
<el-table-column prop="yonghuid" label="用户ID" width="100" />
<el-table-column prop="nicheng" label="昵称" width="150" />
<el-table-column label="角色" min-width="180">
<el-table-column v-if="canManageClubAssignments" label="俱乐部任职" min-width="200">
<template #default="{ row }">
<el-tag v-for="role in row.roles" :key="role.role_code" size="small" style="margin-right: 6px;">
{{ role.role_name }}
<el-tag
v-for="a in row.assignments || []"
:key="a.id"
size="small"
:type="a.is_primary ? 'success' : 'info'"
style="margin-right: 6px; margin-bottom: 4px;"
>
{{ a.club_label }}{{ a.is_primary ? '·主' : '' }}
</el-tag>
<span v-if="!row.roles.length">无角色</span>
<span v-if="!(row.assignments || []).length" class="muted-text">未分配</span>
</template>
</el-table-column>
<el-table-column label="角色" min-width="200">
<template #default="{ row }">
<el-tag
v-for="role in visibleRolesForRow(row)"
:key="`${role.club_id || ''}-${role.role_code}`"
size="small"
style="margin-right: 6px;"
>
{{ roleClubLabel(role) }}
</el-tag>
<span v-if="!visibleRolesForRow(row).length">无角色</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
@@ -93,18 +113,23 @@
<!-- 用户详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
:title="`编辑用户 - ${currentUser?.phone}`"
width="700px"
class="user-dialog"
destroy-on-close
:title="detailDialogTitle"
width="760px"
class="admin-user-dialog"
append-to-body
align-center
:close-on-click-modal="false"
>
<div class="dialog-content">
<div class="dialog-content detail-dialog-body">
<!-- 基本信息 -->
<div class="info-section">
<div class="section-title">基本信息</div>
<el-form :model="editForm" label-width="100px">
<el-form-item label="用户ID">
<span class="info-value">{{ currentUser?.yonghuid }}</span>
</el-form-item>
<el-form-item label="账号">
<span>{{ currentUser?.phone }}</span>
<span class="info-value">{{ currentUser?.phone }}</span>
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="editForm.nicheng" placeholder="请输入昵称" />
@@ -136,27 +161,76 @@
</el-form>
</div>
<!-- 角色管理 -->
<div class="role-section">
<!-- 俱乐部任职仅集团高层可见 -->
<div v-if="canManageClubAssignments" class="club-section">
<div class="section-title">
<span>所属角色</span>
<el-button type="primary" size="small" @click="openAddRoleForUser">
<el-icon><Plus /></el-icon> 添加角色
<span>俱乐部任职</span>
<el-button type="primary" size="small" @click="openAddClubDialog">
<el-icon><Plus /></el-icon> 添加俱乐部
</el-button>
</div>
<div class="role-list">
<div class="club-assign-list">
<div
v-for="role in currentUserRoles"
:key="role.role_code"
class="role-tag"
v-for="a in currentUserAssignments"
:key="a.id"
class="club-assign-item"
>
{{ role.role_name }}
<el-icon class="remove-icon" @click="removeUserRole(role)">
<Close />
</el-icon>
<el-tag :type="a.is_primary ? 'success' : 'info'" size="small">
{{ a.club_label }}{{ a.is_primary ? '(主任职)' : '' }}
</el-tag>
<span class="assign-role-label">{{ a.role_name }}</span>
<el-button type="danger" link size="small" @click="removeClubAssignment(a)">移除</el-button>
</div>
<div v-if="currentUserRoles.length === 0" class="empty-role">
无角色点击上方按钮添加
<div v-if="currentUserAssignments.length === 0" class="empty-role inline-empty">
未分配俱乐部
</div>
</div>
</div>
<!-- 功能角色仅展示/操作有权管理的俱乐部 -->
<div v-if="visibleClubRoleBlocks.length" class="role-section">
<div class="section-title">
<span>功能角色</span>
</div>
<div
v-for="block in visibleClubRoleBlocks"
:key="block.club_id || '__global__'"
class="club-role-block"
>
<div class="club-block-header">
<div>
<strong class="club-block-title">{{ block.club_label }}</strong>
<span class="club-block-meta">已绑 {{ block.roles.length }} </span>
</div>
<el-button
v-if="canManageRolesForClub(block.club_id)"
type="primary"
size="small"
link
@click="openAddRoleForClub(block.club_id)"
>
+ 添加角色
</el-button>
</div>
<div v-if="block.roles.length" class="role-list">
<div
v-for="role in block.roles"
:key="`${block.club_id}-${role.role_code}`"
class="role-tag"
>
{{ role.role_name || role.role_code }}
<el-icon
v-if="canManageRolesForClub(block.club_id)"
class="remove-icon"
@click="removeUserRole(role)"
>
<Close />
</el-icon>
</div>
</div>
<div v-else class="empty-role inline-empty">暂无功能角色</div>
<div v-if="block.catalogRoles.length && canManageRolesForClub(block.club_id)" class="catalog-hint">
可分配{{ block.catalogRoles.map(r => r.role_name).join('、') }}
</div>
</div>
</div>
@@ -171,22 +245,39 @@
<!-- 为用户添加角色的弹窗 -->
<el-dialog
v-model="addRoleForUserVisible"
title="添加角色"
width="500px"
class="role-select-dialog"
title="添加功能角色"
width="520px"
class="admin-user-dialog"
append-to-body
align-center
>
<el-form label-width="100px">
<el-form-item label="所属俱乐部" required>
<el-select v-model="roleBindClubId" placeholder="先选俱乐部" style="width: 100%" @change="selectedRoleCodes = []">
<el-option
v-for="c in roleBindClubOptions"
:key="c.club_id"
:label="c.name"
:value="c.club_id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="role-select-list">
<el-checkbox-group v-model="selectedRoleCodes">
<div
v-for="role in availableRolesForUser"
:key="role.role_code"
:key="`${role.club_id}-${role.role_code}`"
class="role-select-item"
>
<el-checkbox :label="role.role_code">
<span>{{ role.role_name }}</span>
<span class="role-code-tag">{{ role.role_code }}</span>
</el-checkbox>
</div>
</el-checkbox-group>
<div v-if="!roleBindClubId" class="empty-role">请先选择俱乐部</div>
<div v-else-if="!availableRolesForUser.length" class="empty-role">该俱乐部下无可选角色</div>
</div>
<template #footer>
<el-button @click="addRoleForUserVisible = false">取消</el-button>
@@ -194,13 +285,47 @@
</template>
</el-dialog>
<!-- 添加俱乐部任职 -->
<el-dialog v-model="addClubVisible" title="添加俱乐部任职" width="480px" class="admin-user-dialog" append-to-body align-center>
<el-form label-width="100px">
<el-form-item label="俱乐部" required>
<el-select v-model="clubAssignForm.club_id" placeholder="选择俱乐部" style="width: 100%">
<el-option
v-for="c in availableClubsForAssign"
:key="c.club_id"
:label="c.name"
:value="c.club_id"
/>
</el-select>
</el-form-item>
<el-form-item label="任职标签">
<el-select v-model="clubAssignForm.role_code" style="width: 100%">
<el-option label="俱乐部管理员" value="CLUB_ADMIN" />
<el-option label="俱乐部总负责人" value="CLUB_OWNER" />
<el-option label="俱乐部财务" value="CLUB_FINANCE" />
<el-option label="俱乐部售后" value="CLUB_AFTER_SALES" />
<el-option label="俱乐部运营" value="CLUB_OPERATOR" />
</el-select>
</el-form-item>
<el-form-item label="主任职">
<el-switch v-model="clubAssignForm.is_primary" />
</el-form-item>
</el-form>
<div class="form-hint">添加后用户顶栏可切到该俱乐部再为该俱乐部绑功能角色才有菜单</div>
<template #footer>
<el-button @click="addClubVisible = false">取消</el-button>
<el-button type="primary" :loading="assigningClub" @click="confirmAddClubAssignment">保存</el-button>
</template>
</el-dialog>
<!-- 添加用户弹窗 -->
<el-dialog
v-model="addUserDialogVisible"
title="添加后台用户"
width="600px"
class="user-dialog"
destroy-on-close
class="admin-user-dialog"
append-to-body
align-center
>
<div class="dialog-content">
<el-form :model="newUserForm" label-width="100px" ref="addUserFormRef">
@@ -231,11 +356,11 @@
<el-checkbox-group v-model="newUserRoleCodes">
<div
v-for="role in roleList"
:key="role.role_code"
:key="`${role.club_id || ''}-${role.role_code}`"
class="role-select-item"
>
<el-checkbox :label="role.role_code">
<span>{{ role.role_name }}</span>
<span>{{ roleClubLabel(role) }}</span>
</el-checkbox>
</div>
</el-checkbox-group>
@@ -245,22 +370,78 @@
</div>
<template #footer>
<el-button @click="addUserDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddUser">确认添加</el-button>
<el-button type="primary" :loading="addingUser" :disabled="addingUser" @click="confirmAddUser">确认添加</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { JITUAN_API, getAdminClubContext, getAdminClubId, getAdminClubScope } from '@/utils/club-context'
const username = localStorage.getItem('username') || ''
// 权限状态
const clubOptions = ref([])
const clubNameMap = computed(() => {
const m = {}
for (const c of clubOptions.value) m[c.club_id] = c.name
return m
})
const currentViewClubId = computed(() => getAdminClubId())
const isAllClubsView = computed(() => getAdminClubScope() === 'all')
const roleClubLabel = (role) => {
if (!role) return ''
const cid = role.club_id || ''
const cname = cid ? (clubNameMap.value[cid] || cid) : '全局'
return `[${cname}] ${role.role_name || role.role_code || ''}`
}
const canAddUser = computed(() => {
// 本店超级管理manageableClubIds 含当前店,或后端显式 can_manage_club_users
if (canManageClubUsers.value) return true
if (canManageClubAssignments.value) return true
const curr = currentViewClubId.value
if (curr && manageableClubIds.value.includes(curr)) return true
return manageableClubIds.value.length > 0
})
const canManageRolesForClub = (clubId) => {
const cid = clubId || ''
// 单俱乐部顶栏:只允许操作当前店(即使账号有集团任职管理权)
if (!isAllClubsView.value && cid && cid !== currentViewClubId.value) {
return false
}
// 全局角色club_id 空):仅集团汇总视图下的集团账号可管
if (!cid) {
return isAllClubsView.value && canManageClubAssignments.value
}
if (canManageClubAssignments.value && isAllClubsView.value) return true
if (canManageClubAssignments.value && cid === currentViewClubId.value) return true
if (canManageClubUsers.value && cid === currentViewClubId.value) return true
return manageableClubIds.value.includes(cid)
}
const visibleRolesForRow = (row) => {
const roles = row.roles || []
if (isAllClubsView.value && canManageClubAssignments.value) return roles
const curr = currentViewClubId.value
return roles.filter((r) => {
const cid = r.club_id || ''
return !cid || cid === curr
})
}
// 权限状态:集团高管 / 俱乐部超管 / 000001 均可访问
const hasPermission = ref(true)
const canManageClubAssignments = ref(false)
const canManageClubUsers = ref(false)
const manageableClubIds = ref([])
// 角色列表(用于筛选和添加)
const roleList = ref([])
@@ -289,13 +470,108 @@ const editForm = ref({
status: 1
})
const currentUserRoles = ref([])
const currentUserAssignments = ref([])
const detailDialogTitle = computed(() => {
const u = currentUser.value
if (!u) return '编辑用户'
return `编辑用户 - ${u.phone || u.yonghuid || ''}`
})
// 为用户添加角色弹窗
const addRoleForUserVisible = ref(false)
const selectedRoleCodes = ref([])
const roleBindClubId = ref('')
// 俱乐部任职
const addClubVisible = ref(false)
const assigningClub = ref(false)
const clubAssignForm = ref({ club_id: '', role_code: 'CLUB_ADMIN', is_primary: false })
const availableClubsForAssign = computed(() => {
const assigned = new Set(currentUserAssignments.value.map((a) => a.club_id))
let opts = clubOptions.value.filter((c) => !assigned.has(c.club_id))
if (!isAllClubsView.value) {
opts = opts.filter((c) => c.club_id === currentViewClubId.value)
}
return opts
})
const availableRolesForUser = computed(() => {
const existingCodes = currentUserRoles.value.map(r => r.role_code)
return roleList.value.filter(r => !existingCodes.includes(r.role_code))
if (!roleBindClubId.value) return []
const existing = new Set(
currentUserRoles.value
.filter((r) => (r.club_id || '') === roleBindClubId.value)
.map((r) => r.role_code)
)
return roleList.value.filter(
(r) => (r.club_id || '') === roleBindClubId.value && !existing.has(r.role_code)
)
})
const roleBindClubOptions = computed(() => {
const source = (currentUserAssignments.value || [])
.filter((a) => a.club_id && canManageRolesForClub(a.club_id))
.map((a) => ({
club_id: a.club_id,
name: a.club_label || clubNameMap.value[a.club_id] || a.club_id,
}))
if (source.length) {
if (!isAllClubsView.value) {
return source.filter((c) => c.club_id === currentViewClubId.value)
}
return source
}
let opts = clubOptions.value.filter((c) => canManageRolesForClub(c.club_id))
if (!isAllClubsView.value) {
opts = opts.filter((c) => c.club_id === currentViewClubId.value)
}
return opts
})
const clubRoleBlocks = computed(() => {
const blocks = []
const assignments = currentUserAssignments.value || []
const roles = currentUserRoles.value || []
const pushBlock = (clubId, clubLabel) => {
const cid = clubId || ''
if (blocks.some((b) => (b.club_id || '') === cid)) return
blocks.push({
club_id: cid,
club_label: clubLabel || (cid ? (clubNameMap.value[cid] || cid) : '全局角色'),
roles: roles.filter((r) => (r.club_id || '') === cid),
catalogRoles: roleList.value.filter((r) => (r.club_id || '') === cid),
})
}
for (const a of assignments) {
if (a.club_id) {
pushBlock(a.club_id, a.club_label)
}
}
const globalRoles = roles.filter((r) => !r.club_id)
if (globalRoles.length) {
pushBlock('', '全局角色')
}
if (!assignments.length) {
for (const r of roles) {
pushBlock(r.club_id || '', clubNameMap.value[r.club_id] || r.club_id)
}
}
return blocks
})
const visibleClubRoleBlocks = computed(() => {
return clubRoleBlocks.value.filter((b) => {
const cid = b.club_id || ''
if (isAllClubsView.value && canManageClubAssignments.value) return true
if (!cid) return true
return cid === currentViewClubId.value && canManageRolesForClub(cid)
})
})
// 添加用户弹窗
@@ -307,14 +583,26 @@ const newUserForm = ref({
nicheng: ''
})
const newUserRoleCodes = ref([])
const addingUser = ref(false)
// ==================== 接口调用 ====================
// 获取角色列表(第一个接口)
// 获取角色列表:单俱乐部顶栏绝不拉其他店;仅集团汇总视图才允许 all_clubs
const fetchRoleList = async () => {
try {
const res = await request.post('/houtai/hqyhjsgl', { username })
const res = await request.post('/houtai/hqyhjsgl', {
username,
all_clubs: isAllClubsView.value && canManageClubAssignments.value,
})
if (res.code === 0) {
roleList.value = res.data.roles || []
let roles = res.data.roles || []
if (!isAllClubsView.value) {
const curr = currentViewClubId.value
const clubRoles = roles.filter((r) => (r.club_id || '') === curr)
roles = clubRoles.length
? clubRoles
: roles.filter((r) => !(r.club_id || ''))
}
roleList.value = roles
return true
} else if (res.code === 403) {
hasPermission.value = false
@@ -329,6 +617,30 @@ const fetchRoleList = async () => {
}
}
const loadClubOptions = async () => {
try {
const res = await request.get(JITUAN_API.clubList, { skipErrorToast: true })
if (res.code === 0 && Array.isArray(res.data)) {
let opts = res.data.map((c) => ({ club_id: c.club_id, name: c.name }))
if (!isAllClubsView.value) {
const curr = currentViewClubId.value
opts = opts.filter((c) => c.club_id === curr)
}
clubOptions.value = opts
return
}
} catch {
// fallback
}
const ctx = getAdminClubContext()
let opts = ctx?.clubs || []
if (!isAllClubsView.value) {
const curr = currentViewClubId.value
opts = opts.filter((c) => c.club_id === curr)
}
clubOptions.value = opts
}
// 获取用户列表
const fetchUserList = async () => {
if (!hasPermission.value) return
@@ -350,6 +662,18 @@ const fetchUserList = async () => {
if (res.code === 0) {
userList.value = res.data.list || []
total.value = res.data.total || 0
canManageClubAssignments.value = !!res.data.can_manage_club_assignments
canManageClubUsers.value = !!res.data.can_manage_club_users
manageableClubIds.value = res.data.manageable_club_ids || []
// 兜底:列表成功且当前店在 manageable / 有 000001 语义时允许管本店用户
if (
!canManageClubUsers.value
&& manageableClubIds.value.includes(currentViewClubId.value)
) {
canManageClubUsers.value = true
}
} else if (res.code === 403) {
hasPermission.value = false
} else {
ElMessage.error(res.msg || '获取用户列表失败')
}
@@ -368,16 +692,25 @@ const resetFilters = () => {
}
// 打开用户详情弹窗
const openDetailDialog = (user) => {
currentUser.value = user
editForm.value = {
nicheng: user.nicheng || '',
password: '',
erjimima: '',
status: user.status
const openDetailDialog = async (user) => {
if (!user) return
try {
currentUser.value = { ...user }
editForm.value = {
nicheng: user.nicheng || '',
password: '',
erjimima: '',
status: user.status ?? 1,
}
currentUserRoles.value = Array.isArray(user.roles) ? [...user.roles] : []
currentUserAssignments.value = Array.isArray(user.assignments) ? [...user.assignments] : []
detailDialogVisible.value = false
await nextTick()
detailDialogVisible.value = true
} catch (e) {
console.error('openDetailDialog failed:', e)
ElMessage.error('打开详情失败,请刷新页面重试')
}
currentUserRoles.value = [...(user.roles || [])]
detailDialogVisible.value = true
}
// 保存用户修改(昵称、密码、二级密码、状态、角色已通过其他接口单独处理)
@@ -386,6 +719,7 @@ const saveUserInfo = async () => {
const payload = {
username,
action: 'update_user_info',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
nicheng: editForm.value.nicheng,
status: editForm.value.status
@@ -411,13 +745,93 @@ const saveUserInfo = async () => {
}
// 为用户添加角色(打开弹窗)
const openAddRoleForUser = () => {
const openAddRoleForClub = (clubId) => {
selectedRoleCodes.value = []
roleBindClubId.value = clubId || roleBindClubOptions.value[0]?.club_id || ''
addRoleForUserVisible.value = true
}
const openAddRoleForUser = () => {
const curr = currentViewClubId.value
const preferred =
roleBindClubOptions.value.find((c) => c.club_id === curr)?.club_id
|| roleBindClubOptions.value[0]?.club_id
|| curr
|| ''
openAddRoleForClub(preferred)
}
const openAddClubDialog = () => {
clubAssignForm.value = {
club_id: availableClubsForAssign.value[0]?.club_id || '',
role_code: 'CLUB_ADMIN',
is_primary: currentUserAssignments.value.length === 0,
}
addClubVisible.value = true
}
const confirmAddClubAssignment = async () => {
if (!clubAssignForm.value.club_id) {
ElMessage.warning('请选择俱乐部')
return
}
assigningClub.value = true
try {
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'add_club_assignment',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
club_id: clubAssignForm.value.club_id,
role_code: clubAssignForm.value.role_code,
is_primary: clubAssignForm.value.is_primary,
})
if (res.code === 0) {
ElMessage.success('俱乐部任职已添加')
addClubVisible.value = false
await fetchUserList()
const updated = userList.value.find((u) => u.yonghuid === currentUser.value.yonghuid)
if (updated) {
currentUserAssignments.value = [...(updated.assignments || [])]
}
} else {
ElMessage.error(res.msg || '添加失败')
}
} catch {
ElMessage.error('网络错误')
} finally {
assigningClub.value = false
}
}
const removeClubAssignment = async (row) => {
try {
await ElMessageBox.confirm(`移除 ${row.club_label} 任职?`, '提示', { type: 'warning' })
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'remove_club_assignment',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
assignment_id: row.id,
})
if (res.code === 0) {
ElMessage.success('已移除')
currentUserAssignments.value = currentUserAssignments.value.filter((a) => a.id !== row.id)
fetchUserList()
} else {
ElMessage.error(res.msg || '移除失败')
}
} catch {
// cancel
}
}
// 确认添加角色
const confirmAddUserRoles = async () => {
if (!roleBindClubId.value) {
ElMessage.warning('请先选择俱乐部')
return
}
if (selectedRoleCodes.value.length === 0) {
ElMessage.warning('请至少选择一个角色')
return
@@ -426,18 +840,20 @@ const confirmAddUserRoles = async () => {
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'add_user_roles',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
role_codes: selectedRoleCodes.value
role_codes: selectedRoleCodes.value,
target_club_id: roleBindClubId.value,
})
if (res.code === 0) {
ElMessage.success('角色添加成功')
// 刷新当前用户的角色列表
const updatedUser = userList.value.find(u => u.phone === currentUser.value.phone)
if (updatedUser) {
currentUserRoles.value = [...(updatedUser.roles || []), ...roleList.value.filter(r => selectedRoleCodes.value.includes(r.role_code))]
}
addRoleForUserVisible.value = false
fetchUserList() // 刷新列表
await fetchUserList()
const updated = userList.value.find((u) => u.yonghuid === currentUser.value.yonghuid)
if (updated) {
currentUserRoles.value = [...(updated.roles || [])]
currentUserAssignments.value = [...(updated.assignments || [])]
}
} else {
ElMessage.error(res.msg || '添加失败')
}
@@ -452,8 +868,10 @@ const removeUserRole = async (role) => {
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'remove_user_role',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
role_code: role.role_code
role_code: role.role_code,
target_club_id: role.club_id || '',
})
if (res.code === 0) {
ElMessage.success('角色移除成功')
@@ -477,6 +895,7 @@ const openAddUserDialog = () => {
// 确认添加用户
const confirmAddUser = async () => {
if (addingUser.value) return
if (!newUserForm.value.phone || !newUserForm.value.password || !newUserForm.value.erjimima || !newUserForm.value.nicheng) {
ElMessage.warning('请填写完整信息')
return
@@ -485,6 +904,11 @@ const confirmAddUser = async () => {
ElMessage.warning('密码和二级密码至少6位')
return
}
if (newUserRoleCodes.value.length === 0) {
ElMessage.warning('请至少选择一个角色')
return
}
addingUser.value = true
try {
const res = await request.post('/houtai/tjyhjs', {
username,
@@ -503,18 +927,21 @@ const confirmAddUser = async () => {
}
} catch (error) {
ElMessage.error('网络错误')
} finally {
addingUser.value = false
}
}
// 初始化
// 初始化:先拉用户列表(含 manageable_club_ids再拉角色列表
onMounted(async () => {
if (!username) {
ElMessage.error('未获取到用户信息')
return
}
const success = await fetchRoleList()
if (success) {
await fetchUserList()
await loadClubOptions()
await fetchUserList()
if (hasPermission.value) {
await fetchRoleList()
}
})
</script>
@@ -607,28 +1034,6 @@ onMounted(async () => {
display: flex;
justify-content: flex-end;
}
:deep(.el-dialog) {
background: rgba(6,12,20,0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(0,242,255,0.3);
border-radius: 20px;
}
:deep(.el-dialog__title) {
color: #00f2ff;
}
:deep(.el-form-item__label) {
color: rgba(255,255,255,0.8);
}
:deep(.el-input__wrapper) {
background: rgba(0,0,0,0.5);
border-color: rgba(0,242,255,0.3);
}
:deep(.el-input__inner) {
color: #fff;
}
:deep(.el-input__inner::placeholder) {
color: rgba(255,255,255,0.4);
}
.section-title {
font-size: 18px;
font-weight: bold;
@@ -665,6 +1070,63 @@ onMounted(async () => {
padding: 20px 0;
text-align: center;
}
.empty-role.inline-empty {
padding: 8px 0;
text-align: left;
}
.muted-text {
color: rgba(255,255,255,0.45);
font-size: 13px;
}
.club-assign-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.club-assign-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
}
.assign-role-label {
flex: 1;
color: rgba(255,255,255,0.65);
font-size: 13px;
}
.club-role-block {
margin-bottom: 16px;
padding: 14px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(0, 242, 255, 0.18);
border-radius: 10px;
}
.club-block-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.club-block-title {
color: #00f2ff;
font-size: 15px;
}
.club-block-meta {
margin-left: 10px;
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
}
.catalog-hint {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.scope-hint {
margin-bottom: 12px;
}
.role-select-list {
max-height: 400px;
overflow-y: auto;
@@ -675,9 +1137,95 @@ onMounted(async () => {
background: rgba(255,255,255,0.05);
border-radius: 8px;
}
.role-code-tag {
margin-left: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.form-hint {
font-size: 12px;
color: rgba(255,255,255,0.5);
margin-top: 4px;
}
</style>
<!-- 弹窗 teleport body必须用非 scoped 样式 -->
<style>
.admin-user-dialog.el-dialog {
background: linear-gradient(165deg, #0a1018 0%, #121c2e 100%) !important;
border: 1px solid rgba(0, 242, 255, 0.35) !important;
border-radius: 14px !important;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.65) !important;
}
.admin-user-dialog .el-dialog__header {
border-bottom: 1px solid rgba(0, 242, 255, 0.2);
margin-right: 0;
padding-bottom: 12px;
}
.admin-user-dialog .el-dialog__title {
color: #00f2ff !important;
font-weight: 600;
}
.admin-user-dialog .el-dialog__body {
color: #e8eeff !important;
background: transparent !important;
padding-top: 16px;
}
.admin-user-dialog .el-dialog__footer {
border-top: 1px solid rgba(0, 242, 255, 0.15);
}
.admin-user-dialog .el-form-item__label {
color: rgba(232, 238, 255, 0.88) !important;
}
.admin-user-dialog .info-value {
color: #ffffff !important;
font-weight: 500;
}
.admin-user-dialog .el-input__wrapper {
background: rgba(0, 0, 0, 0.45) !important;
box-shadow: 0 0 0 1px rgba(0, 242, 255, 0.28) inset !important;
}
.admin-user-dialog .el-input__inner {
color: #ffffff !important;
}
.admin-user-dialog .el-input__inner::placeholder {
color: rgba(255, 255, 255, 0.35) !important;
}
.admin-user-dialog .el-radio__label {
color: #e8eeff !important;
}
.admin-user-dialog .el-select .el-input__inner {
color: #fff !important;
}
.admin-user-dialog .section-title {
color: #00f2ff !important;
}
.admin-user-dialog .role-tag {
color: #fff !important;
background: rgba(0, 242, 255, 0.12) !important;
border-color: rgba(0, 242, 255, 0.35) !important;
}
.admin-user-dialog .club-role-block {
background: rgba(0, 0, 0, 0.35) !important;
}
.admin-user-dialog .empty-role {
color: rgba(255, 255, 255, 0.55) !important;
}
.admin-user-dialog .assign-role-label,
.admin-user-dialog .catalog-hint,
.admin-user-dialog .form-hint {
color: rgba(255, 255, 255, 0.6) !important;
}
.admin-user-dialog .role-select-item {
background: rgba(255, 255, 255, 0.06) !important;
}
.admin-user-dialog .el-checkbox__label {
color: #e8eeff !important;
}
.admin-user-dialog .el-alert__title {
color: #e6a23c !important;
}
.admin-user-dialog .el-alert__description {
color: rgba(255, 255, 255, 0.65) !important;
}
</style>

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,25 +50,161 @@
<el-input v-model="form.wx_secret" type="password" show-password placeholder="小程序 Secret" />
</el-form-item>
<el-divider content-position="left">微信支付</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" />
<el-input v-model="form.pay_app_id" placeholder="可与 wx_appid 相同,或留空" />
</el-form-item>
<el-form-item label="api_v3_key">
<el-input v-model="form.api_v3_key" type="password" show-password />
<el-form-item label="收款 APIv2 密钥">
<el-input
v-model="form.mch_key"
type="password"
show-password
placeholder="商户平台 → API安全 → APIv2密钥"
/>
</el-form-item>
<el-form-item label="cert_serial_no">
<el-input v-model="form.cert_serial_no" />
<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="private_key_path">
<el-input v-model="form.private_key_path" placeholder="服务器上私钥文件路径" />
<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>
<el-form-item label="platform_cert_dir">
<el-input v-model="form.platform_cert_dir" placeholder="平台证书目录" />
<!-- ========== 提现商户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="商户平台 API安全 APIv3密钥"
/>
</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"
:closable="false"
show-icon
class="pay-warn"
:title="payWarnings.join('')"
/>
<el-divider content-position="left">服务号</el-divider>
<el-form-item label="official_appid">
@@ -108,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'
@@ -118,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({
@@ -126,12 +263,31 @@ const createForm = reactive({
wx_appid: '',
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: '',
wx_secret: '',
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: '',
@@ -152,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) {
@@ -172,7 +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 || '加载失败')
}
@@ -183,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 {
@@ -195,6 +492,7 @@ const saveClub = async () => {
if (res.code === 0) {
ElMessage.success('保存成功')
await loadClubList()
await loadClub()
} else {
ElMessage.error(res.msg || '保存失败')
}
@@ -238,7 +536,7 @@ const createClub = async () => {
ElMessage.error(res.msg || '创建失败')
}
} catch {
ElMessage.error('创建失败,请确认账号有 000001 权限且后端已部署')
ElMessage.error('创建失败,请确认账号有权限且后端已部署')
} finally {
creating.value = false
}
@@ -257,6 +555,9 @@ onMounted(async () => {
.tip {
margin-bottom: 16px;
}
.section-tip {
margin-bottom: 12px;
}
.toolbar {
display: flex;
align-items: center;
@@ -264,6 +565,24 @@ onMounted(async () => {
margin-bottom: 20px;
}
.config-form {
max-width: 720px;
max-width: 820px;
}
.field-help {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.pay-warn {
margin: 12px 0 20px;
}
.upload-row {
display: flex;
gap: 8px;
width: 100%;
align-items: center;
}
.upload-row .el-input {
flex: 1;
}
</style>

View File

@@ -7,8 +7,8 @@
:closable="false"
show-icon
class="scope-tip"
title="操作权限在「角色管理」里配"
description="查看会员=3300a、财务=caiwu、订单=002ab 等:角色管理 → 角色详情 → 添加权限。给账号绑角色:管理员用户。"
title="多俱乐部 + 分俱乐部权限(推荐)"
description="1本页「添加任职」同一账号可添加多条分别指定星阙、星之界等俱乐部无需集团权限即可顶栏切换。2「角色管理」为各俱乐部创建角色并配权限码。3「管理员用户」绑定角色时请先在顶栏切到对应俱乐部再绑或传 target_club_id。切换俱乐部后菜单/权限按该俱乐部的角色生效。"
/>
<el-alert
@@ -16,8 +16,8 @@
:closable="false"
show-icon
class="scope-tip"
title="集团高管 / 查看全部订单与用户"
description="功能权限:角色管理里给「订单 002ab」「用户列表」等权限。数据范围本页添加任职范围选「全部俱乐部 ALL_CLUBS」登录后顶栏切「集团汇总」即可看全部订单/财务/用户(统计按各俱乐部汇总,不串单俱乐部数据)。"
title="集团汇总(可选)"
description="仅当需要一次看全部子公司汇总数据时,才添加「集团(全部子公司)」任职。普通多俱乐部负责人不必给集团权限,分别添加各俱乐部任职即可。"
/>
<div class="action-bar">
@@ -167,7 +167,7 @@
<el-option label="俱乐部售后" value="CLUB_AFTER_SALES" />
<el-option label="俱乐部运营" value="CLUB_OPERATOR" />
</el-select>
<div class="role-hint">任职角色只是备注标签<strong>不能</strong>替代角色管理里的功能权限</div>
<div class="role-hint">任职角色为管理标签实际菜单权限请在角色管理为该俱乐部创建角色并在管理员用户绑定</div>
</el-form-item>
<el-form-item label="主任职">

View File

@@ -0,0 +1,181 @@
<template>
<div class="order-grab-share" v-loading="loading">
<el-alert
type="info"
:closable="false"
show-icon
title="单向互通:配置「抢单方」可抢「订单方」的待接单。反向需另加一条。关停请删除规则。"
style="margin-bottom: 16px"
/>
<div class="toolbar">
<el-select v-model="form.grabber_club_id" placeholder="抢单方俱乐部" filterable style="width: 200px">
<el-option
v-for="c in clubOptions"
:key="c.club_id"
:label="`${c.name} (${c.club_id})`"
:value="c.club_id"
/>
</el-select>
<span class="arrow">可抢 </span>
<el-select v-model="form.order_club_id" placeholder="订单所属俱乐部" filterable style="width: 200px">
<el-option
v-for="c in clubOptions"
:key="`o-${c.club_id}`"
:label="`${c.name} (${c.club_id})`"
:value="c.club_id"
/>
</el-select>
<el-button type="primary" :loading="adding" @click="addLink">添加规则</el-button>
<el-button @click="fetchList">刷新</el-button>
</div>
<el-table :data="list" border style="margin-top: 16px">
<el-table-column prop="grabber_club_id" label="抢单方" min-width="160">
<template #default="{ row }">
{{ row.grabber_club_name }} ({{ row.grabber_club_id }})
</template>
</el-table-column>
<el-table-column prop="order_club_id" label="可抢订单所属" min-width="160">
<template #default="{ row }">
{{ row.order_club_name }} ({{ row.order_club_id }})
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="removeLink(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
const username = localStorage.getItem('username') || ''
const loading = ref(false)
const adding = ref(false)
const list = ref([])
const clubOptions = ref([])
const form = reactive({
grabber_club_id: '',
order_club_id: '',
})
async function loadClubs() {
try {
const res = await request.get(JITUAN_API.clubList, { skipErrorToast: true })
if (res.code === 0 && Array.isArray(res.data)) {
clubOptions.value = res.data.map((c) => ({
club_id: c.club_id,
name: c.name || c.club_id,
}))
}
} catch {
clubOptions.value = []
}
}
async function fetchList() {
loading.value = true
try {
const res = await request.post(JITUAN_API.orderGrabLink, {
username,
action: 'list',
})
if (res.code === 0) {
list.value = res.data?.list || []
} else {
ElMessage.error(res.msg || '加载失败')
}
} catch {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
async function addLink() {
if (!form.grabber_club_id || !form.order_club_id) {
ElMessage.warning('请选择抢单方与订单方俱乐部')
return
}
if (form.grabber_club_id === form.order_club_id) {
ElMessage.warning('不能配置本店抢本店')
return
}
adding.value = true
try {
const res = await request.post(JITUAN_API.orderGrabLink, {
username,
action: 'add',
grabber_club_id: form.grabber_club_id,
order_club_id: form.order_club_id,
})
if (res.code === 0) {
ElMessage.success('已添加')
form.order_club_id = ''
await fetchList()
} else {
ElMessage.error(res.msg || '添加失败')
}
} catch {
ElMessage.error('网络错误')
} finally {
adding.value = false
}
}
async function removeLink(row) {
try {
await ElMessageBox.confirm(
`删除规则:${row.grabber_club_name} 可抢 ${row.order_club_name}`,
'确认',
{ type: 'warning' },
)
} catch {
return
}
try {
const res = await request.post(JITUAN_API.orderGrabLink, {
username,
action: 'delete',
id: row.id,
})
if (res.code === 0) {
ElMessage.success('已删除')
await fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch {
ElMessage.error('网络错误')
}
}
onMounted(async () => {
await loadClubs()
await fetchList()
})
</script>
<style scoped>
.order-grab-share {
padding: 16px 20px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.arrow {
color: #666;
font-size: 13px;
}
</style>

View File

@@ -266,6 +266,7 @@ const saveRoleInfo = async () => {
username,
action: 'update_role',
role_code: currentRole.value.role_code,
target_club_id: currentRole.value.club_id || '',
role_name: editForm.value.role_name,
description: editForm.value.description,
perm_codes: permCodes
@@ -293,7 +294,8 @@ const deleteRole = (role) => {
const res = await request.post('/houtai/sctjjsnr', {
username,
action: 'delete_role',
role_code: role.role_code
role_code: role.role_code,
target_club_id: role.club_id || '',
})
if (res.code === 0) {
ElMessage.success('删除成功')
@@ -339,6 +341,7 @@ const confirmAddPermissions = async () => {
username,
action: 'add_permission',
role_code: currentRole.value.role_code,
target_club_id: currentRole.value.club_id || '',
perm_code: permCode
})
}
@@ -346,7 +349,10 @@ const confirmAddPermissions = async () => {
// 刷新当前角色权限列表
const res = await request.post('/houtai/gljshq', { username })
if (res.code === 0) {
const updatedRole = res.data.roles.find(r => r.role_code === currentRole.value.role_code)
const updatedRole = res.data.roles.find(
(r) => r.role_code === currentRole.value.role_code
&& (r.club_id || '') === (currentRole.value.club_id || '')
)
if (updatedRole) {
currentRolePermissions.value = updatedRole.permissions || []
}
@@ -365,6 +371,7 @@ const removePermission = async (perm) => {
username,
action: 'remove_permission',
role_code: currentRole.value.role_code,
target_club_id: currentRole.value.club_id || '',
perm_code: perm.perm_code
})
if (res.code === 0) {

View File

@@ -282,7 +282,7 @@ const saveChenghao = async () => {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('网络错误')
ElMessage.error(e?.response?.data?.msg || e?.message || '网络错误')
} finally {
saving.value = false
}
@@ -311,7 +311,7 @@ const deleteChenghao = async () => {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
ElMessage.error('网络错误')
ElMessage.error(e?.response?.data?.msg || e?.message || '网络错误')
}
}

View File

@@ -510,6 +510,7 @@ const closeAddDialog = () => {
}
const submitAddExaminer = async () => {
if (addSaving.value) return
const uid = (addForm.yonghuid || '').trim()
if (!uid) {
ElMessage.warning('请输入用户ID')
@@ -535,7 +536,7 @@ const submitAddExaminer = async () => {
ElMessage.error(res.msg || '添加失败')
}
} catch (e) {
ElMessage.error('网络错误')
ElMessage.error(e?.response?.data?.msg || e?.message || '网络错误')
} finally {
addSaving.value = false
}

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

View File

@@ -0,0 +1,406 @@
<template>
<div class="script-config-manager">
<el-alert
v-if="isAllClubScope"
type="warning"
:closable="false"
show-icon
title="客服聊天配置需在具体俱乐部视图下编辑,请切换顶栏俱乐部。"
class="scope-alert"
/>
<ClubScopeHint v-else />
<div v-if="!hasPermission" class="no-permission">
<div class="no-permission-icon">🔒</div>
<div class="no-permission-text">您无权访问此页面</div>
<div class="no-permission-desc">请联系管理员开通 kfhs666 8080a 权限</div>
</div>
<div v-else>
<div class="header-bar">
<h2>客服聊天配置</h2>
<p class="header-desc">欢迎语发图确认弹窗关键词自动回复按当前俱乐部生效</p>
</div>
<el-tabs v-model="activeTab" class="scene-tabs">
<el-tab-pane label="聊天发图确认" name="chat_image_confirm" />
<el-tab-pane label="客服欢迎语" name="cs_welcome" />
<el-tab-pane label="商家派单确认" name="merchant_dispatch_confirm" />
<el-tab-pane label="客服自动回复" name="cs_auto_reply" />
</el-tabs>
<el-card v-if="activeTab !== 'cs_auto_reply'" class="single-card" shadow="hover">
<template #header>
<div class="card-header">
<span>{{ currentSceneLabel }}</span>
<el-tag :type="singleForm.is_active ? 'success' : 'info'" size="small">
{{ singleForm.is_active ? '启用' : '禁用' }}
</el-tag>
</div>
</template>
<el-form :model="singleForm" label-width="120px" class="single-form">
<el-form-item v-if="isConfirmScene" label="弹窗标题">
<el-input v-model="singleForm.title" placeholder="弹窗标题" />
</el-form-item>
<el-form-item label="正文内容">
<el-input v-model="singleForm.content" type="textarea" :rows="6" placeholder="话术正文" />
</el-form-item>
<el-form-item v-if="isConfirmScene" label="确认按钮">
<el-input v-model="singleForm.confirm_text" placeholder="如:我已知晓,继续发送" />
</el-form-item>
<el-form-item v-if="isConfirmScene" label="取消按钮">
<el-input v-model="singleForm.cancel_text" placeholder="如:取消" />
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="singleForm.is_active" :disabled="isAllClubScope" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="singleSubmitting" :disabled="isAllClubScope" @click="saveSingle">
保存{{ singleForm.id ? '修改' : '创建' }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<div v-else class="auto-reply-panel">
<div class="toolbar">
<el-button type="primary" :disabled="isAllClubScope" @click="openAutoReplyDialog()">+ 添加自动回复规则</el-button>
</div>
<el-table :data="autoReplyList" border stripe>
<el-table-column prop="priority" label="优先级" width="90" />
<el-table-column label="触发关键词" min-width="200">
<template #default="{ row }">
<el-tag v-for="kw in row.keywords" :key="kw" size="small" class="kw-tag">{{ kw }}</el-tag>
<span v-if="!row.keywords || !row.keywords.length"></span>
</template>
</el-table-column>
<el-table-column prop="content" label="回复内容" min-width="260" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :disabled="isAllClubScope" @click="openAutoReplyDialog(row)">编辑</el-button>
<el-button type="danger" link :disabled="isAllClubScope" @click="deleteAutoReply(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog
v-model="autoReplyDialogVisible"
:title="autoReplyForm.id ? '编辑自动回复' : '添加自动回复'"
width="560px"
destroy-on-close
>
<el-form :model="autoReplyForm" label-width="110px">
<el-form-item label="触发关键词" required>
<el-select
v-model="autoReplyForm.keywords"
multiple
filterable
allow-create
default-first-option
placeholder="输入后回车添加关键词"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="autoReplyForm.priority" :min="0" :max="999" />
</el-form-item>
<el-form-item label="回复内容" required>
<el-input v-model="autoReplyForm.content" type="textarea" :rows="5" />
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="autoReplyForm.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="autoReplyDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="autoReplySubmitting" @click="submitAutoReply">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { getAdminClubScope } from '@/utils/club-context'
const username = ref(localStorage.getItem('username') || '')
const hasPermission = ref(true)
const activeTab = ref('cs_welcome')
const allItems = ref([])
const singleSubmitting = ref(false)
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const SCENE_LABELS = {
chat_image_confirm: '聊天发图确认',
cs_welcome: '客服欢迎语',
merchant_dispatch_confirm: '商家派单确认',
cs_auto_reply: '客服自动回复',
}
const singleForm = reactive({
id: null,
scene_key: 'cs_welcome',
item_type: 'text_display',
title: '',
content: '',
confirm_text: '我知道了',
cancel_text: '取消',
is_active: true,
})
const currentSceneLabel = computed(() => SCENE_LABELS[activeTab.value] || activeTab.value)
const isConfirmScene = computed(() => activeTab.value !== 'cs_welcome')
const autoReplyList = computed(() =>
allItems.value.filter((i) => i.scene_key === 'cs_auto_reply' && i.item_type === 'auto_reply')
)
const autoReplyDialogVisible = ref(false)
const autoReplySubmitting = ref(false)
const autoReplyForm = reactive({
id: null,
keywords: [],
priority: 0,
content: '',
is_active: true,
})
const fetchData = async () => {
try {
const res = await request.post('/houtai/hthqhs', { username: username.value })
if (res.code === 0) {
allItems.value = res.data.items || []
loadSingleForm()
} else if (res.code === 403) {
hasPermission.value = false
} else {
ElMessage.error(res.msg || '获取数据失败')
}
} catch {
ElMessage.error('网络错误')
}
}
const getSingleItemType = (sceneKey) => {
return sceneKey === 'cs_welcome' ? 'text_display' : 'confirm_modal'
}
const loadSingleForm = () => {
const sceneKey = activeTab.value
if (sceneKey === 'cs_auto_reply') return
const item = allItems.value.find(
(i) => i.scene_key === sceneKey && i.item_type === getSingleItemType(sceneKey)
)
if (item) {
Object.assign(singleForm, {
id: item.id,
scene_key: item.scene_key,
item_type: item.item_type,
title: item.title || '',
content: item.content || '',
confirm_text: item.confirm_text || '我知道了',
cancel_text: item.cancel_text || '取消',
is_active: item.is_active,
})
} else {
Object.assign(singleForm, {
id: null,
scene_key: sceneKey,
item_type: getSingleItemType(sceneKey),
title: '',
content: '',
confirm_text: '我知道了',
cancel_text: '取消',
is_active: true,
})
}
}
watch(activeTab, () => {
loadSingleForm()
})
const saveSingle = async () => {
if (isAllClubScope.value) return
if (!singleForm.content || !singleForm.content.trim()) {
ElMessage.warning('请填写正文内容')
return
}
singleSubmitting.value = true
try {
const action = singleForm.id ? 'update' : 'create'
const payload = {
username: username.value,
action,
scene_key: singleForm.scene_key,
item_type: singleForm.item_type,
title: singleForm.title,
content: singleForm.content.trim(),
confirm_text: singleForm.confirm_text,
cancel_text: singleForm.cancel_text,
is_active: singleForm.is_active,
}
if (singleForm.id) payload.id = singleForm.id
const res = await request.post('/houtai/htxghs', payload)
if (res.code === 0) {
ElMessage.success('保存成功')
await fetchData()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch {
ElMessage.error('保存失败')
} finally {
singleSubmitting.value = false
}
}
const openAutoReplyDialog = (row) => {
if (row) {
Object.assign(autoReplyForm, {
id: row.id,
keywords: [...(row.keywords || [])],
priority: row.priority || 0,
content: row.content || '',
is_active: row.is_active,
})
} else {
Object.assign(autoReplyForm, {
id: null,
keywords: [],
priority: 0,
content: '',
is_active: true,
})
}
autoReplyDialogVisible.value = true
}
const submitAutoReply = async () => {
if (isAllClubScope.value) return
if (!autoReplyForm.keywords.length) {
ElMessage.warning('请至少添加一个触发关键词')
return
}
if (!autoReplyForm.content || !autoReplyForm.content.trim()) {
ElMessage.warning('请填写回复内容')
return
}
autoReplySubmitting.value = true
try {
const action = autoReplyForm.id ? 'update' : 'create'
const payload = {
username: username.value,
action,
scene_key: 'cs_auto_reply',
item_type: 'auto_reply',
title: '',
content: autoReplyForm.content.trim(),
keywords: autoReplyForm.keywords,
priority: autoReplyForm.priority,
is_active: autoReplyForm.is_active,
}
if (autoReplyForm.id) payload.id = autoReplyForm.id
const res = await request.post('/houtai/htxghs', payload)
if (res.code === 0) {
ElMessage.success('保存成功')
autoReplyDialogVisible.value = false
await fetchData()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch {
ElMessage.error('保存失败')
} finally {
autoReplySubmitting.value = false
}
}
const deleteAutoReply = async (row) => {
if (isAllClubScope.value) return
try {
await ElMessageBox.confirm('确定删除该自动回复规则?', '删除确认', { type: 'warning' })
const res = await request.post('/houtai/htxghs', {
username: username.value,
action: 'delete',
id: row.id,
})
if (res.code === 0) {
ElMessage.success('删除成功')
await fetchData()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') ElMessage.error('删除失败')
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.script-config-manager {
padding: 16px;
}
.scope-alert {
margin-bottom: 12px;
}
.header-bar {
margin-bottom: 16px;
}
.header-bar h2 {
margin: 0 0 8px;
font-size: 20px;
}
.header-desc {
margin: 0;
color: #888;
font-size: 13px;
}
.no-permission {
text-align: center;
padding: 80px 20px;
}
.no-permission-icon {
font-size: 48px;
margin-bottom: 16px;
}
.single-card {
margin-top: 12px;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
}
.auto-reply-panel {
margin-top: 12px;
}
.toolbar {
margin-bottom: 12px;
}
.kw-tag {
margin-right: 6px;
margin-bottom: 4px;
}
</style>

View File

@@ -282,8 +282,7 @@ const fetchClubs = async () => {
// ---------- 2. 获取订单类型 ----------
const fetchOrderTypes = async () => {
try {
const username = localStorage.getItem('username')
const res = await request.post('/yonghu/kfhqptddlx', { phone: username })
const res = await request.post('/yonghu/kfhqptddlx', {}, { skipErrorToast: true })
if (res.code === 0) {
orderTypes.value = res.data || []
// 默认选中全部(空字符串)

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>

View File

@@ -1,8 +1,587 @@
<template>
<div class="member-data-page">
<ClubScopeHint />
<div class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form" @submit.prevent="search">
<el-form-item label="订单ID">
<el-input v-model="filters.dingdan_id" clearable placeholder="订单ID" class="dark-input" style="width: 160px" />
</el-form-item>
<el-form-item label="购买人ID">
<el-input v-model="filters.yonghuid" clearable placeholder="打手ID" class="dark-input" style="width: 120px" />
</el-form-item>
<el-form-item label="购买人昵称">
<el-input v-model="filters.buyer_keyword" clearable placeholder="昵称关键词" class="dark-input" style="width: 140px" />
</el-form-item>
<el-form-item label="会员类型">
<el-select v-model="filters.huiyuan_id" clearable filterable placeholder="全部" class="dark-select" style="width: 180px">
<el-option
v-for="item in huiyuanOptions"
:key="item.huiyuan_id"
:label="`${item.jieshao}${item.is_bundle ? ' [总]' : ''}`"
:value="item.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="filters.zhuangtai" clearable placeholder="全部" class="dark-select" style="width: 120px">
<el-option label="已支付" :value="3" />
<el-option label="待支付" :value="9" />
<el-option label="履约失败" :value="8" />
<el-option label="已取消" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="订单类型">
<el-select v-model="filters.is_trial" clearable placeholder="全部" class="dark-select" style="width: 120px">
<el-option label="正式会员" :value="false" />
<el-option label="体验会员" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="总会员单">
<el-select v-model="filters.is_bundle" clearable placeholder="全部" class="dark-select" style="width: 120px">
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="已分红">
<el-select v-model="filters.has_fenhong" clearable placeholder="全部" class="dark-select" style="width: 120px">
<el-option label="已分红" :value="true" />
<el-option label="未分红" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="管事ID">
<el-input v-model="filters.guanshi_id" clearable placeholder="管事ID" class="dark-input" style="width: 120px" />
</el-form-item>
<el-form-item label="组长ID">
<el-input v-model="filters.zuzhang_id" clearable placeholder="组长ID" class="dark-input" style="width: 120px" />
</el-form-item>
<el-form-item label="金额区间">
<el-input-number v-model="filters.jine_min" :min="0" :precision="2" controls-position="right" placeholder="最低" class="dark-input-number" style="width: 120px" />
<span class="range-sep">-</span>
<el-input-number v-model="filters.jine_max" :min="0" :precision="2" controls-position="right" placeholder="最高" class="dark-input-number" style="width: 120px" />
</el-form-item>
<el-form-item label="购买时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD HH:mm:ss"
class="dark-date-picker"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" clearable placeholder="订单/用户/说明" class="dark-input" style="width: 160px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search" :loading="loading">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="summary-grid">
<div class="summary-card">
<div class="s-label">记录总数</div>
<div class="s-value">{{ summary.total_count }}</div>
</div>
<div class="summary-card">
<div class="s-label">已支付笔数</div>
<div class="s-value">{{ summary.paid_count }}</div>
</div>
<div class="summary-card">
<div class="s-label">待支付笔数</div>
<div class="s-value">{{ summary.pending_count }}</div>
</div>
<div class="summary-card">
<div class="s-label">已付金额合计</div>
<div class="s-value income">¥{{ formatMoney(summary.paid_jine) }}</div>
</div>
<div class="summary-card">
<div class="s-label">管事分红合计</div>
<div class="s-value">¥{{ formatMoney(summary.guanshi_fenhong_total) }}</div>
</div>
<div class="summary-card">
<div class="s-label">组长分红合计</div>
<div class="s-value">¥{{ formatMoney(summary.zuzhang_fenhong_total) }}</div>
</div>
<div class="summary-card">
<div class="s-label">体验单数</div>
<div class="s-value">{{ summary.trial_count }}</div>
</div>
</div>
<div class="table-card" v-loading="loading">
<el-table :data="list" border class="member-data-table" style="width: 100%" row-key="dingdan_id">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel" v-if="row.fenhong">
<div class="expand-title">分红明细</div>
<div class="expand-grid">
<div class="expand-item">
<span class="k">管事</span>
<span class="v">{{ row.fenhong.guanshi_nicheng }} ({{ row.fenhong.guanshi_id }})</span>
</div>
<div class="expand-item">
<span class="k">管事分红</span>
<span class="v money">¥{{ formatMoney(row.fenhong.guanshi_fenhong) }}</span>
</div>
<div class="expand-item">
<span class="k">组长</span>
<span class="v">{{ row.fenhong.zuzhang_nicheng || '-' }} ({{ row.fenhong.zuzhang_id || '-' }})</span>
</div>
<div class="expand-item">
<span class="k">组长分红</span>
<span class="v money">¥{{ formatMoney(row.fenhong.zuzhang_fenhong) }}</span>
</div>
<div class="expand-item">
<span class="k">第几次正式购买</span>
<span class="v highlight">{{ row.fenhong.formal_cishu ?? row.formal_cishu ?? '-' }}</span>
</div>
<div class="expand-item">
<span class="k">分红时间</span>
<span class="v">{{ formatDate(row.fenhong.fenhong_time) }}</span>
</div>
<div class="expand-item">
<span class="k">体验分红</span>
<span class="v">{{ row.fenhong.is_trial ? '是' : '否' }}</span>
</div>
<div class="expand-item expand-item-wide">
<span class="k">说明</span>
<span class="v">{{ row.fenhong.shuoming || '-' }}</span>
</div>
</div>
</div>
<div v-else class="expand-panel expand-panel-empty">暂无分红记录可能未支付或尚未履约</div>
</template>
</el-table-column>
<el-table-column prop="dingdan_id" label="订单ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="CreateTime" label="购买时间" min-width="170">
<template #default="{ row }">{{ formatDate(row.CreateTime) }}</template>
</el-table-column>
<el-table-column label="购买人" min-width="140">
<template #default="{ row }">
<div>{{ row.buyer_nicheng }}</div>
<div class="sub-id">{{ row.yonghuid }}</div>
</template>
</el-table-column>
<el-table-column label="会员类型" min-width="140">
<template #default="{ row }">
<div>
{{ row.huiyuan_name }}
<el-tag v-if="row.is_bundle" size="small" type="success"></el-tag>
<el-tag v-if="row.is_trial" size="small" type="warning">体验</el-tag>
</div>
<div class="sub-id">{{ row.huiyuan_id }}</div>
</template>
</el-table-column>
<el-table-column prop="jine" label="付款金额" min-width="100">
<template #default="{ row }">¥{{ formatMoney(row.jine) }}</template>
</el-table-column>
<el-table-column prop="purchase_days" label="天数" width="70" />
<el-table-column prop="zhuangtai_label" label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="row.zhuangtai === 3 ? 'success' : (row.zhuangtai === 8 ? 'danger' : (row.zhuangtai === 2 ? 'info' : 'warning'))"
size="small"
>{{ row.zhuangtai_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="正式第N次" width="100">
<template #default="{ row }">{{ row.formal_cishu ?? '-' }}</template>
</el-table-column>
<el-table-column label="管事分红" min-width="100">
<template #default="{ row }">
<span v-if="row.fenhong">¥{{ formatMoney(row.fenhong.guanshi_fenhong) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="组长分红" min-width="100">
<template #default="{ row }">
<span v-if="row.fenhong">¥{{ formatMoney(row.fenhong.zuzhang_fenhong) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="club_id" label="俱乐部" width="80" />
<el-table-column prop="shuoming" label="说明" min-width="120" show-overflow-tooltip />
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="fetchData"
@size-change="onSizeChange"
/>
</div>
</div>
</div>
</template>
<script>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
const username = localStorage.getItem('username') || ''
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const huiyuanOptions = ref([])
const dateRange = ref([])
const filters = reactive({
dingdan_id: '',
yonghuid: '',
buyer_keyword: '',
huiyuan_id: '',
zhuangtai: '',
is_trial: '',
is_bundle: '',
has_fenhong: '',
guanshi_id: '',
zuzhang_id: '',
jine_min: null,
jine_max: null,
keyword: '',
})
const summary = reactive({
total_count: 0,
paid_count: 0,
pending_count: 0,
paid_jine: 0,
guanshi_fenhong_total: 0,
zuzhang_fenhong_total: 0,
trial_count: 0,
})
const formatMoney = (val) => {
if (val === undefined || val === null || val === '') return '0.00'
return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const formatDate = (val) => {
if (!val) return '-'
return String(val).replace('T', ' ').slice(0, 19)
}
const buildPayload = () => {
const payload = {
username,
page: page.value,
page_size: pageSize.value,
}
Object.entries(filters).forEach(([key, val]) => {
if (val !== '' && val !== null && val !== undefined) {
payload[key] = val
}
})
if (dateRange.value?.length === 2) {
payload.date_from = dateRange.value[0]
payload.date_to = dateRange.value[1]
}
return payload
}
const fetchData = async () => {
if (!username) {
ElMessage.error('未登录,请重新登录')
return
}
loading.value = true
try {
const res = await request.post(JITUAN_API.memberRechargeRecords, buildPayload())
if (res.code === 0) {
const data = res.data || {}
list.value = data.list || []
total.value = data.total || 0
huiyuanOptions.value = data.filter_options?.huiyuan_list || []
Object.assign(summary, data.summary || {})
} else {
ElMessage.error(res.msg || '查询失败')
}
} catch (e) {
console.error(e)
ElMessage.error('查询失败')
} finally {
loading.value = false
}
}
const search = () => {
page.value = 1
fetchData()
}
const resetFilters = () => {
Object.assign(filters, {
dingdan_id: '',
yonghuid: '',
buyer_keyword: '',
huiyuan_id: '',
zhuangtai: '',
is_trial: '',
is_bundle: '',
has_fenhong: '',
guanshi_id: '',
zuzhang_id: '',
jine_min: null,
jine_max: null,
keyword: '',
})
dateRange.value = []
search()
}
const onSizeChange = () => {
page.value = 1
fetchData()
}
onMounted(fetchData)
</script>
<style>
</style>
<style scoped>
.member-data-page {
padding: 20px;
min-height: 100vh;
color: #c0e0ff;
}
.filter-card,
.table-card {
background: rgba(18, 25, 35, 0.85);
border: 1px solid rgba(64, 158, 255, 0.15);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.filter-form :deep(.el-form-item__label) {
color: #9ec5ff;
}
.range-sep {
margin: 0 8px;
color: #9ec5ff;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
background: rgba(18, 25, 35, 0.85);
border: 1px solid rgba(64, 158, 255, 0.15);
border-radius: 10px;
padding: 14px;
}
.s-label {
font-size: 12px;
color: #8fb8e8;
margin-bottom: 6px;
}
.s-value {
font-size: 20px;
font-weight: 600;
color: #e6f3ff;
}
.s-value.income {
color: #67c23a;
}
.sub-id {
font-size: 12px;
color: #9ec5ff;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.expand-panel {
padding: 16px 20px;
background: #060d14;
border: 1px solid rgba(64, 158, 255, 0.35);
border-radius: 8px;
margin: 4px 0;
}
.expand-panel-empty {
color: #ffffff;
font-size: 14px;
}
.expand-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 14px;
color: #6cc3e8;
letter-spacing: 0.5px;
}
.expand-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px 16px;
}
.expand-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: #0f1a28;
border: 1px solid rgba(64, 158, 255, 0.2);
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
}
.expand-item-wide {
grid-column: 1 / -1;
}
.expand-item .k {
flex-shrink: 0;
min-width: 88px;
color: #6cc3e8;
font-weight: 600;
}
.expand-item .v {
color: #ffffff;
word-break: break-all;
}
.expand-item .v.money {
color: #7ee787;
font-weight: 700;
font-size: 15px;
}
.expand-item .v.highlight {
color: #ffd666;
font-weight: 700;
}
/* 展开行单元格:强制深色底,避免 Element 默认白/灰底 */
.member-data-table :deep(.el-table__expanded-cell) {
background: #060d14 !important;
padding: 12px 16px !important;
}
.member-data-table :deep(.el-table__expanded-cell:hover) {
background: #060d14 !important;
}
.member-data-table :deep(.el-table__expand-icon) {
color: #6cc3e8;
font-size: 16px;
}
.member-data-table :deep(.el-table__expand-icon .el-icon) {
color: #6cc3e8;
}
/* 表单控件深色 */
.filter-form :deep(.el-input__wrapper),
.filter-form :deep(.el-select .el-input__wrapper),
.filter-form :deep(.el-input-number .el-input__wrapper),
.filter-form :deep(.el-date-editor .el-input__wrapper) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.filter-form :deep(.el-input__inner),
.filter-form :deep(.el-input-number__input),
.filter-form :deep(.el-range-input) {
color: #e6f3ff !important;
background: transparent !important;
}
.filter-form :deep(.el-input__inner::placeholder),
.filter-form :deep(.el-range-input::placeholder) {
color: rgba(159, 197, 255, 0.45) !important;
}
.filter-form :deep(.el-range-separator) {
color: #8fb8e8 !important;
}
.filter-form :deep(.el-input-number__decrease),
.filter-form :deep(.el-input-number__increase) {
background: #0d1824 !important;
border-color: #2a6f8f !important;
color: #9ec5ff !important;
}
.filter-form :deep(.el-select .el-select__caret),
.filter-form :deep(.el-input__suffix),
.filter-form :deep(.el-input__prefix) {
color: #8fb8e8 !important;
}
/* 表格统一深色,去掉白条纹 */
.member-data-table :deep(.el-table) {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: rgba(12, 18, 28, 0.55);
--el-table-header-bg-color: rgba(20, 30, 45, 0.95);
--el-table-row-hover-bg-color: rgba(64, 158, 255, 0.1);
--el-table-text-color: #ffffff;
--el-table-header-text-color: #9ec5ff;
--el-table-border-color: rgba(64, 158, 255, 0.12);
background: transparent;
}
.member-data-table :deep(.el-table th.el-table__cell) {
background: rgba(20, 30, 45, 0.95) !important;
color: #9ec5ff !important;
}
.member-data-table :deep(.el-table td.el-table__cell) {
background: rgba(12, 18, 28, 0.55) !important;
color: #ffffff !important;
border-bottom: 1px solid rgba(64, 158, 255, 0.1) !important;
}
.member-data-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: rgba(64, 158, 255, 0.1) !important;
}
.member-data-table :deep(.el-table__empty-text) {
color: rgba(159, 197, 255, 0.55);
}
/* 分页深色 */
.pager :deep(.el-pagination) {
--el-pagination-bg-color: transparent;
--el-pagination-text-color: #9ec5ff;
--el-pagination-button-color: #9ec5ff;
--el-pagination-hover-color: #6cc3e8;
}
.pager :deep(.el-pagination .el-select .el-input__wrapper),
.pager :deep(.el-pagination .btn-prev),
.pager :deep(.el-pagination .btn-next),
.pager :deep(.el-pagination .el-pager li) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
color: #9ec5ff !important;
}
.pager :deep(.el-pagination .el-pager li.is-active) {
background: rgba(64, 158, 255, 0.25) !important;
color: #e6f3ff !important;
border-color: #409eff !important;
}
.pager :deep(.el-pagination__total),
.pager :deep(.el-pagination__jump) {
color: #9ec5ff !important;
}
.pager :deep(.el-pagination__editor.el-input .el-input__wrapper) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
}
.pager :deep(.el-pagination__editor.el-input .el-input__inner) {
color: #e6f3ff !important;
}
/* 按钮 */
.filter-form :deep(.el-button:not(.el-button--primary)) {
background: #0a121c;
border-color: #2a6f8f;
color: #9ec5ff;
}
.filter-form :deep(.el-button:not(.el-button--primary):hover) {
background: rgba(64, 158, 255, 0.12);
border-color: #409eff;
color: #e6f3ff;
}
</style>

View File

@@ -24,7 +24,16 @@
show-icon
class="member-scope-tip"
title="集团汇总视图"
description="可新建全局会员类型;修改售价/体验配置请切换到具体俱乐部。"
description="可新建全局会员类型(含总会员开关);修改售价/体验/装饰图请切换到具体俱乐部。"
/>
<el-alert
v-if="!isAllClubScope"
type="success"
:closable="false"
show-icon
class="member-scope-tip"
title="总会员(包含式)"
description="点击「新建总会员」可一步创建并上架;需先上架至少一个普通会员作为包含项。也可在集团汇总先建类型,再在本俱乐部「上架会员」选用。"
/>
<div class="action-bar">
@@ -34,12 +43,10 @@
@click="openAddDialog"
:icon="Plus"
>新建全局会员类型</el-button>
<el-button
v-else
type="primary"
@click="openCatalogDialog"
:icon="Plus"
>上架会员</el-button>
<template v-else>
<el-button type="success" @click="openBundleDialog">新建总会员</el-button>
<el-button type="primary" @click="openCatalogDialog" :icon="Plus">上架会员</el-button>
</template>
</div>
<div class="member-grid" v-loading="loading">
@@ -47,7 +54,8 @@
<div class="card-header">
<div class="member-name-wrap">
<div class="member-name">{{ member.jieshao }}</div>
<el-tag v-if="member.trial_enabled" size="small" type="warning" class="trial-tag">体验可售</el-tag>
<el-tag v-if="member.is_bundle" size="small" type="success" class="trial-tag">总会员</el-tag>
<el-tag v-if="member.trial_enabled && !member.is_bundle" size="small" type="warning" class="trial-tag">体验可售</el-tag>
</div>
<div class="member-price">¥{{ formatMoney(member.jiage) }}<span class="days-hint">/{{ member.formal_days || 30 }}</span></div>
<div
@@ -113,10 +121,10 @@
<el-form-item label="组长分成(元)" prop="zuzhangfc">
<el-input-number v-model="currentMember.zuzhangfc" :min="0" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
</el-form-item>
<el-form-item label="开启体验会员">
<el-form-item label="开启体验会员" v-if="!currentMember.is_bundle">
<el-switch v-model="currentMember.trial_enabled" :disabled="!isEditMode" />
</el-form-item>
<template v-if="currentMember.trial_enabled">
<template v-if="currentMember.trial_enabled && !currentMember.is_bundle">
<el-form-item label="体验价格(元)" prop="trial_price">
<el-input-number v-model="currentMember.trial_price" :min="0.01" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
</el-form-item>
@@ -133,6 +141,66 @@
<el-form-item label="详细规则介绍" prop="jtjieshao">
<el-input v-model="currentMember.jtjieshao" type="textarea" rows="4" :disabled="!isEditMode" />
</el-form-item>
<el-alert
v-if="isAllClubScope"
type="warning"
:closable="false"
show-icon
title="装饰图需在具体俱乐部下配置"
description="请先在顶栏切换到具体俱乐部,再点「编辑」上传充值页卡片装饰图/背景图。"
class="member-card-scope-tip"
/>
<template v-else>
<el-form-item v-if="currentMember.is_bundle" label="包含的子会员">
<el-select
v-model="currentMember.included_huiyuan_ids"
multiple
filterable
:disabled="!isEditMode"
placeholder="选择本俱乐部已上架的普通会员"
style="width: 100%"
>
<el-option
v-for="opt in clubSubMemberOptions"
:key="opt.huiyuan_id"
:label="`${opt.jieshao} (${opt.huiyuan_id})`"
:value="opt.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item label="充值页卡片装饰图">
<div class="member-card-upload">
<el-image v-if="currentMember.card_image" :src="memberCardUrl(currentMember.card_image)" fit="contain" class="member-card-preview" />
<el-upload
v-if="isEditMode"
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadMemberCard(f, 'card_image')"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="cardUploadField === 'card_image'">上传装饰图</el-button>
</el-upload>
<span v-else-if="!currentMember.card_image" class="member-card-hint">点击「编辑」后可上传</span>
</div>
</el-form-item>
<el-form-item label="充值页卡片背景图">
<div class="member-card-upload">
<el-image v-if="currentMember.card_bg" :src="memberCardUrl(currentMember.card_bg)" fit="contain" class="member-card-preview" />
<el-upload
v-if="isEditMode"
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadMemberCard(f, 'card_bg')"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="cardUploadField === 'card_bg'">上传背景图</el-button>
</el-upload>
<span v-else-if="!currentMember.card_bg" class="member-card-hint">点击「编辑」后可上传</span>
</div>
</el-form-item>
</template>
<el-form-item label="购买次数" v-if="!isEditMode">
<el-input :value="currentMember.goumai_cishu || 0" disabled />
</el-form-item>
@@ -169,6 +237,10 @@
<el-form-item label="详细规则介绍" prop="jtjieshao">
<el-input v-model="addForm.jtjieshao" type="textarea" rows="4" placeholder="选填" />
</el-form-item>
<el-form-item label="总会员(包含式)">
<el-switch v-model="addForm.is_bundle" />
<span class="form-hint-inline">开启后可在俱乐部配置包含哪些子会员类型,无体验版</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
@@ -176,6 +248,62 @@
</template>
</el-dialog>
<el-dialog
v-model="bundleDialogVisible"
title="新建总会员包含式"
width="640px"
@close="closeBundleDialog"
>
<el-alert
type="info"
:closable="false"
show-icon
class="enable-tip"
title="一步创建并上架"
description="将自动创建全局总会员类型并在当前俱乐部上架请至少选择一个已上架的普通会员作为包含项"
/>
<el-form :model="bundleForm" :rules="bundleRules" ref="bundleFormRef" label-width="130px">
<el-form-item label="会员名称" prop="jieshao">
<el-input v-model="bundleForm.jieshao" maxlength="30" placeholder="全能总会员" />
</el-form-item>
<el-form-item label="正式价格()" prop="jiage">
<el-input-number v-model="bundleForm.jiage" :min="0.01" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="正式天数" prop="formal_days">
<el-input-number v-model="bundleForm.formal_days" :min="1" :max="3650" style="width: 100%" />
</el-form-item>
<el-form-item label="管事分成()" prop="guanshifc">
<el-input-number v-model="bundleForm.guanshifc" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="组长分成()" prop="zuzhangfc">
<el-input-number v-model="bundleForm.zuzhangfc" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="包含的子会员" prop="included_huiyuan_ids">
<el-select
v-model="bundleForm.included_huiyuan_ids"
multiple
filterable
placeholder="选择本俱乐部已上架的普通会员至少一个"
style="width: 100%"
>
<el-option
v-for="opt in enableSubMemberOptions"
:key="opt.huiyuan_id"
:label="`${opt.jieshao} (${opt.huiyuan_id})`"
:value="opt.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item label="详细规则介绍">
<el-input v-model="bundleForm.jtjieshao" type="textarea" rows="3" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bundleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitBundle" :loading="bundling">创建并上架</el-button>
</template>
</el-dialog>
<el-dialog
v-model="catalogDialogVisible"
title="从集团选用会员上架"
@@ -191,7 +319,10 @@
class="catalog-item"
@click="openEnableDialog(item)"
>
<div class="catalog-name">{{ item.jieshao }}</div>
<div class="catalog-name">
{{ item.jieshao }}
<el-tag v-if="item.is_bundle" size="small" type="success" style="margin-left:8px">总会员</el-tag>
</div>
<div class="catalog-id">ID: {{ item.huiyuan_id }}</div>
</div>
</div>
@@ -213,6 +344,15 @@
:title="enableTarget.jieshao"
:description="`会员 ID${enableTarget.huiyuan_id}`"
/>
<el-alert
v-if="enableTarget.is_bundle"
type="success"
:closable="false"
show-icon
class="enable-tip"
title="总会员包含式"
description="无体验版请配置价格/天数并选择本俱乐部已上架的普通会员作为包含项"
/>
<el-form :model="enableForm" :rules="enableRules" ref="enableFormRef" label-width="130px">
<el-form-item label="正式价格()" prop="jiage">
<el-input-number v-model="enableForm.jiage" :min="0.01" :precision="2" :step="1" style="width: 100%" />
@@ -226,10 +366,26 @@
<el-form-item label="组长分成()" prop="zuzhangfc">
<el-input-number v-model="enableForm.zuzhangfc" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="开启体验会员">
<el-form-item v-if="enableTarget.is_bundle" label="包含的子会员" prop="included_huiyuan_ids">
<el-select
v-model="enableForm.included_huiyuan_ids"
multiple
filterable
placeholder="选择本俱乐部已上架的普通会员至少一个"
style="width: 100%"
>
<el-option
v-for="opt in enableSubMemberOptions"
:key="opt.huiyuan_id"
:label="`${opt.jieshao} (${opt.huiyuan_id})`"
:value="opt.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="!enableTarget.is_bundle" label="开启体验会员">
<el-switch v-model="enableForm.trial_enabled" />
</el-form-item>
<template v-if="enableForm.trial_enabled">
<template v-if="enableForm.trial_enabled && !enableTarget.is_bundle">
<el-form-item label="体验价格()" prop="trial_price">
<el-input-number v-model="enableForm.trial_price" :min="0.01" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
@@ -268,12 +424,59 @@ const memberList = ref([])
const priceNote = ref('')
const clubId = ref('xq')
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const clubSubMemberOptions = computed(() =>
(memberList.value || []).filter(
(m) => !m.is_bundle && m.huiyuan_id !== currentMember.value?.huiyuan_id,
),
)
const enableSubMemberOptions = computed(() =>
(memberList.value || []).filter(
(m) => !m.is_bundle && m.huiyuan_id !== enableTarget.value?.huiyuan_id,
),
)
const detailDialogVisible = ref(false)
const isEditMode = ref(false)
const currentMember = ref({})
const detailFormRef = ref(null)
const saving = ref(false)
const cardUploadField = ref('')
function memberCardUrl(rel) {
if (!rel) return ''
if (rel.startsWith('http')) return rel
const base = window.$ossURL || ''
return base + rel
}
async function uploadMemberCard(file, field) {
if (!file?.raw || !currentMember.value?.huiyuan_id) return
cardUploadField.value = field
try {
const fd = new FormData()
fd.append('username', username.value)
fd.append('huiyuan_id', currentMember.value.huiyuan_id)
fd.append('field', field)
fd.append('file', file.raw)
const res = await request.post(JITUAN_API.memberCardUpload, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.code === 0) {
const newPath = res.data?.[field] || ''
currentMember.value[field] = newPath
// 同步列表卡片上的预览,避免仍显示旧路径
const hit = memberList.value.find((m) => m.huiyuan_id === currentMember.value.huiyuan_id)
if (hit) hit[field] = newPath
ElMessage.success('上传成功')
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch (e) {
ElMessage.error('上传失败')
} finally {
cardUploadField.value = ''
}
}
const addDialogVisible = ref(false)
const addFormRef = ref(null)
@@ -283,9 +486,28 @@ const addForm = ref({
jiage: 0,
guanshifc: 0,
zuzhangfc: 0,
jtjieshao: ''
jtjieshao: '',
is_bundle: false,
})
const bundleDialogVisible = ref(false)
const bundleFormRef = ref(null)
const bundling = ref(false)
const bundleForm = ref({
jieshao: '',
jiage: 0,
formal_days: 30,
guanshifc: 0,
zuzhangfc: 0,
jtjieshao: '',
included_huiyuan_ids: [],
})
const bundleRules = {
jieshao: [{ required: true, message: '请输入会员名称', trigger: 'blur' }],
jiage: [{ required: true, message: '请输入价格', trigger: 'blur' }],
included_huiyuan_ids: [{ type: 'array', required: true, min: 1, message: '请至少选择一个子会员', trigger: 'change' }],
}
const catalogDialogVisible = ref(false)
const catalogLoading = ref(false)
const catalogList = ref([])
@@ -306,7 +528,8 @@ function defaultClubPriceForm() {
trial_price: 0,
trial_days: 7,
trial_guanshifc: 0,
trial_zuzhangfc: 0
trial_zuzhangfc: 0,
included_huiyuan_ids: [],
}
}
@@ -321,7 +544,11 @@ function normalizeMember(member) {
trial_price: Number(member.trial_price || 0),
trial_days: Number(member.trial_days || 0),
trial_guanshifc: Number(member.trial_guanshifc || 0),
trial_zuzhangfc: Number(member.trial_zuzhangfc || 0)
trial_zuzhangfc: Number(member.trial_zuzhangfc || 0),
card_image: member.card_image || '',
card_bg: member.card_bg || '',
is_bundle: Boolean(member.is_bundle),
included_huiyuan_ids: Array.isArray(member.included_huiyuan_ids) ? [...member.included_huiyuan_ids] : [],
}
}
@@ -435,6 +662,10 @@ const saveMember = async () => {
if (!validatePriceSplit(m.jiage, m.guanshifc, m.zuzhangfc, m.trial_enabled, m.trial_price, m.trial_guanshifc, m.trial_zuzhangfc)) {
return
}
if (m.is_bundle && (!m.included_huiyuan_ids || m.included_huiyuan_ids.length === 0)) {
ElMessage.error('总会员请至少选择一个包含的子会员')
return
}
try {
await ElMessageBox.confirm('确定保存修改吗?', '确认', {
confirmButtonText: '确认',
@@ -454,6 +685,9 @@ const saveMember = async () => {
guanshifc: m.guanshifc,
zuzhangfc: m.zuzhangfc,
jtjieshao: m.jtjieshao || '',
card_image: m.card_image || '',
card_bg: m.card_bg || '',
included_huiyuan_ids: m.is_bundle ? (m.included_huiyuan_ids || []) : undefined,
...buildTrialPayload(m)
})
if (res.code === 0) {
@@ -488,7 +722,8 @@ const openAddDialog = () => {
jiage: 0,
guanshifc: 0,
zuzhangfc: 0,
jtjieshao: ''
jtjieshao: '',
is_bundle: false,
}
addDialogVisible.value = true
}
@@ -525,7 +760,8 @@ const submitAdd = async () => {
jiage: addForm.value.jiage,
guanshifc: addForm.value.guanshifc,
zuzhangfc: addForm.value.zuzhangfc,
jtjieshao: addForm.value.jtjieshao || ''
jtjieshao: addForm.value.jtjieshao || '',
is_bundle: addForm.value.is_bundle,
})
if (res.code === 0) {
ElMessage.success('创建成功')
@@ -542,6 +778,77 @@ const submitAdd = async () => {
}
}
const openBundleDialog = () => {
if (isAllClubScope.value) {
ElMessage.warning('请切换到具体俱乐部后再新建总会员')
return
}
if (enableSubMemberOptions.value.length === 0) {
ElMessage.warning('请先上架至少一个普通会员,再创建总会员')
return
}
bundleForm.value = {
jieshao: '',
jiage: 0,
formal_days: 30,
guanshifc: 0,
zuzhangfc: 0,
jtjieshao: '',
included_huiyuan_ids: enableSubMemberOptions.value.map((m) => m.huiyuan_id),
}
bundleDialogVisible.value = true
}
const closeBundleDialog = () => {
bundleDialogVisible.value = false
bundleFormRef.value?.resetFields()
}
const submitBundle = async () => {
try {
await bundleFormRef.value?.validate()
} catch {
return
}
const f = bundleForm.value
if (f.jiage <= 0) {
ElMessage.error('正式价格必须大于 0')
return
}
if (!validatePriceSplit(f.jiage, f.guanshifc, f.zuzhangfc, false, 0, 0, 0)) {
return
}
if (!f.included_huiyuan_ids?.length) {
ElMessage.error('请至少选择一个包含的子会员')
return
}
bundling.value = true
try {
const res = await request.post(JITUAN_API.memberBundleCreate, {
username: username.value,
jieshao: f.jieshao,
jiage: f.jiage,
formal_days: f.formal_days,
guanshifc: f.guanshifc,
zuzhangfc: f.zuzhangfc,
jtjieshao: f.jtjieshao || '',
included_huiyuan_ids: f.included_huiyuan_ids,
})
if (res.code === 0) {
ElMessage.success('总会员创建并上架成功')
bundleDialogVisible.value = false
await fetchMemberList()
} else {
ElMessage.error(res.msg || '创建失败')
}
} catch (error) {
console.error(error)
ElMessage.error('创建失败')
} finally {
bundling.value = false
}
}
const openCatalogDialog = async () => {
if (isAllClubScope.value) {
ElMessage.warning('请切换到具体俱乐部后再上架会员')
@@ -571,8 +878,11 @@ const closeCatalogDialog = () => {
}
const openEnableDialog = (item) => {
enableTarget.value = item
enableTarget.value = { ...item, is_bundle: Boolean(item.is_bundle) }
enableForm.value = defaultClubPriceForm()
if (item.is_bundle) {
enableForm.value.trial_enabled = false
}
enableDialogVisible.value = true
catalogDialogVisible.value = false
}
@@ -597,16 +907,32 @@ const submitEnable = async () => {
if (!validatePriceSplit(f.jiage, f.guanshifc, f.zuzhangfc, f.trial_enabled, f.trial_price, f.trial_guanshifc, f.trial_zuzhangfc)) {
return
}
if (enableTarget.value.is_bundle) {
if (!f.included_huiyuan_ids || f.included_huiyuan_ids.length === 0) {
ElMessage.error('总会员请至少选择一个包含的子会员')
return
}
if (enableSubMemberOptions.value.length === 0) {
ElMessage.error('请先将至少一个普通会员上架,再配置总会员')
return
}
}
enabling.value = true
try {
const res = await request.post(JITUAN_API.memberEnable, {
const payload = {
username: username.value,
huiyuan_id: enableTarget.value.huiyuan_id,
jiage: f.jiage,
formal_days: f.formal_days,
guanshifc: f.guanshifc,
zuzhangfc: f.zuzhangfc,
...buildTrialPayload(f)
})
...buildTrialPayload(f),
}
if (enableTarget.value.is_bundle) {
payload.trial_enabled = false
payload.included_huiyuan_ids = f.included_huiyuan_ids
}
const res = await request.post(JITUAN_API.memberEnable, payload)
if (res.code === 0) {
ElMessage.success('上架成功')
enableDialogVisible.value = false
@@ -779,6 +1105,30 @@ onMounted(() => {
color: #8ab3cf;
margin-top: 4px;
}
.member-card-upload {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.member-card-preview {
width: 160px;
height: 100px;
border-radius: 8px;
background: #0d2137;
}
.member-card-hint {
font-size: 12px;
color: #8ab3cf;
}
.member-card-scope-tip {
margin-bottom: 12px;
}
.form-hint-inline {
margin-left: 8px;
font-size: 12px;
color: #8ab3cf;
}
.no-permission {
text-align: center;
padding: 80px 20px;

View File

@@ -12,10 +12,14 @@
<div class="header-bar">
<h2>轮播图与公告管理</h2>
<el-select v-model="pageKey" placeholder="选择页面" @change="onPageKeyChange">
<el-select v-model="pageKey" placeholder="选择页面" @change="onPageKeyChange" style="min-width: 280px">
<el-option v-for="opt in pageOptions" :key="opt.key" :label="opt.label" :value="opt.key" />
</el-select>
</div>
<p class="page-hint">
UFO 点单端主轮播选点单页主轮播公告下第二横幅选点单页第二横幅可多图
我的顶区背景选点单个人中心背景可多图改图前请切到具体俱乐部 ufo勿在集团视图下上传
</p>
<el-card class="section-card" shadow="never">
<template #header><strong>{{ gonggaoTitle }}</strong></template>
@@ -104,15 +108,43 @@ import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
/** 本地兜底:即使接口 page_options 仍是旧列表,也要保证 UFO 两项可选 */
const DEFAULT_PAGE_OPTIONS = [
{ key: 'order_pool', label: '抢单池' },
{ key: 'accept_order', label: '点单页主轮播' },
{ key: 'accept_order_banner2', label: '点单页第二横幅(可多图)' },
{ key: 'merchant_home', label: '商家首页' },
{ key: 'dashou_center', label: '打手个人中心背景' },
{ key: 'boss_center', label: '点单个人中心背景(可多图)' },
]
function mergePageOptions(fromServer) {
const map = new Map()
DEFAULT_PAGE_OPTIONS.forEach((o) => map.set(o.key, { ...o }))
;(fromServer || []).forEach((o) => {
if (!o || !o.key) return
const prev = map.get(o.key)
map.set(o.key, {
key: o.key,
label: (prev && prev.label) || o.label || o.key,
})
})
// 固定顺序:默认项在前,其余追加
const ordered = []
DEFAULT_PAGE_OPTIONS.forEach((o) => {
if (map.has(o.key)) {
ordered.push(map.get(o.key))
map.delete(o.key)
}
})
map.forEach((v) => ordered.push(v))
return ordered
}
const username = ref(localStorage.getItem('username') || '')
const hasPermission = ref(true)
const pageKey = ref('order_pool')
const pageOptions = ref([
{ key: 'order_pool', label: '抢单池' },
{ key: 'accept_order', label: '点单页' },
{ key: 'merchant_home', label: '商家首页' },
{ key: 'dashou_center', label: '打手个人中心背景' },
])
const pageOptions = ref(mergePageOptions([]))
const gonggaoTitle = computed(() => {
const opt = pageOptions.value.find((o) => o.key === pageKey.value)
const label = opt ? opt.label : pageKey.value
@@ -165,15 +197,7 @@ const fetchLunbo = async () => {
})
if (res.code === 0) {
lunboList.value = res.data?.list || []
pageOptions.value = res.data?.page_options || []
if (!pageOptions.value.length) {
pageOptions.value = [
{ key: 'order_pool', label: '抢单池' },
{ key: 'accept_order', label: '点单页' },
{ key: 'merchant_home', label: '商家首页' },
{ key: 'dashou_center', label: '打手个人中心背景' },
]
}
pageOptions.value = mergePageOptions(res.data?.page_options)
} else if (res.code === 403) {
hasPermission.value = false
} else {
@@ -350,6 +374,16 @@ onMounted(async () => {
color: #909399;
font-size: 13px;
}
.page-hint {
margin: -8px 0 16px;
padding: 10px 14px;
background: #ecf5ff;
border: 1px solid #d9ecff;
border-radius: 6px;
color: #409eff;
font-size: 13px;
line-height: 1.55;
}
.header-bar {
display: flex;
justify-content: space-between;

View File

@@ -9,14 +9,36 @@
<ClubScopeHint />
<h2>小程序图标与频道</h2>
<p class="hint">
图标固定文件名存于 COS<code>beijing/tubiao/chengxutubiao/</code>
频道图片存于 <code>beijing/pindao/</code>
推广海报背景存于 <code>beijing/haibao/</code>各俱乐部独立配置
图标<strong>当前顶部所选俱乐部</strong>独立配置 club_miniapp_icon
请先切到具体俱乐部再上传集团汇总模式下不可改
文件存于 COS<code>beijing/tubiao/chengxutubiao/</code>
频道 <code>beijing/pindao/</code>海报背景 <code>beijing/haibao/</code>
</p>
<el-card class="section-card" shadow="never">
<template #header><strong>点单端我的服务图标</strong></template>
<p class="hint">在线客服邀请好友我推荐的人须分开配置避免共用同一张图</p>
<div class="icon-grid">
<div v-for="item in cuserMineIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="icon-preview" />
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">更换</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>推广海报背景(按俱乐部)</strong></template>
<p class="hint">管事/组长海报合成时的底图小程序进入海报页自动拉取</p>
<p class="hint">管事 / 组长 / 点单端推荐官海报合成底图,按当前俱乐部独立配置;点单端请单独上传,勿与管事共用。</p>
<div class="icon-grid">
<div v-for="item in posterIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
@@ -36,9 +58,177 @@
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>功能图标(按俱乐部)</strong></template>
<template #header><strong>商家端图标</strong></template>
<div class="icon-grid">
<div v-for="item in funcIcons" :key="item.icon_key" class="icon-cell">
<div v-for="item in merchantIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="icon-preview" />
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">更换</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>打手中心-充值横幅(会员/保证金大图标)</strong></template>
<p class="hint">打手「我的」页钱包下方左右两张可点击大图,上传后立即生效。</p>
<div class="icon-grid">
<div v-for="item in fighterBannerIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="poster-preview" />
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">更换横幅</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>打手中心-我的页图标</strong></template>
<p class="hint">订单区、交易明细、好友邀请、快手、投诉记录、我的处罚等(投诉与处罚须分开上传)。</p>
<div class="icon-grid">
<div v-for="item in fighterMinePageIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="icon-preview" />
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">更换</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>打手中心-更多功能图标</strong></template>
<div class="icon-grid">
<div v-for="item in fighterMoreFuncIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="icon-preview" />
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">更换</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>接单池-订单卡背景</strong></template>
<p class="hint">平台 / 优质商家 / 普通商家三类抢单卡片背景。未上传时小程序保持原白卡;此处始终可直接上传。</p>
<div class="icon-grid">
<div v-for="item in grabCardBgIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image
v-if="previewUrl(item)"
:src="previewUrl(item)"
fit="contain"
class="poster-preview"
/>
<div v-else class="empty-preview">暂无图片</div>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">
{{ item.is_uploaded ? '更换背景' : '上传背景' }}
</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>打手「我的」- 会员/保证金充值页大背景</strong></template>
<p class="hint">会员充值页顶图、保证金页顶图与右侧装饰。未上传时小程序用默认样式;此处始终可直接上传。</p>
<div class="icon-grid">
<div v-for="item in fighterPageBgIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image
v-if="previewUrl(item)"
:src="previewUrl(item)"
fit="contain"
class="poster-preview"
/>
<div v-else class="empty-preview">暂无图片</div>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">
{{ item.is_uploaded ? '更换背景' : '上传背景' }}
</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>打手中心-会员/保证金充值页图标</strong></template>
<div class="icon-grid">
<div v-for="item in fighterRechargeIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image
v-if="previewUrl(item)"
:src="previewUrl(item)"
fit="contain"
class="icon-preview"
/>
<div v-else class="empty-preview empty-preview-sm">暂无图片</div>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(f) => uploadIcon(f, item.icon_key)"
accept="image/jpeg,image/png,image/jpg,image/webp"
>
<el-button size="small" type="primary" :loading="uploadingKey === item.icon_key">
{{ item.is_uploaded ? '更换' : '上传' }}
</el-button>
</el-upload>
</div>
</div>
</el-card>
<el-card class="section-card" shadow="never">
<template #header><strong>通用图标</strong></template>
<div class="icon-grid">
<div v-for="item in commonIcons" :key="item.icon_key" class="icon-cell">
<div class="icon-label">{{ item.label }}</div>
<div class="icon-key">{{ item.icon_key }}</div>
<el-image :src="fullUrl(item.image_path)" fit="contain" class="icon-preview" />
@@ -104,9 +294,73 @@ const username = ref(localStorage.getItem('username') || '')
const REQUIRED_PERMISSION = '8080a'
const hasPermission = ref(true)
const icons = ref([])
const POSTER_KEYS = new Set(['guanshi_poster_bg', 'zuzhang_poster_bg'])
const POSTER_KEYS = new Set(['guanshi_poster_bg', 'zuzhang_poster_bg', 'cuser_poster_bg'])
const CUSER_MINE_KEYS = new Set([
'cuser_mine_kefu',
'cuser_mine_invite',
'cuser_mine_invitees',
])
const COMMON_KEYS = new Set(['mine_pindao', 'icon_refresh'])
const GRAB_CARD_BG_META = [
{ icon_key: 'grab_card_pingtai_bg', label: '接单池-平台订单卡背景' },
{ icon_key: 'grab_card_youzhi_bg', label: '接单池-优质商家卡背景' },
{ icon_key: 'grab_card_shangjia_bg', label: '接单池-普通商家卡背景' },
]
const GRAB_CARD_BG_KEYS = new Set(GRAB_CARD_BG_META.map((i) => i.icon_key))
/** 充值页大背景(未上传也不下发),单独成组方便运营上传 */
const FIGHTER_PAGE_BG_META = [
{ icon_key: 'fighter_recharge_vip_bg', label: '打手会员充值页-顶部背景图' },
{ icon_key: 'fighter_deposit_bg', label: '打手保证金页-顶部背景图' },
{ icon_key: 'fighter_deposit_hero', label: '打手保证金页-卡片右侧装饰图' },
]
const FIGHTER_PAGE_BG_KEYS = new Set(FIGHTER_PAGE_BG_META.map((i) => i.icon_key))
const CONFIG_ONLY_PREVIEW_KEYS = new Set([
...GRAB_CARD_BG_KEYS,
...FIGHTER_PAGE_BG_KEYS,
])
const FIGHTER_BANNER_KEYS = new Set(['fighter_recharge_member', 'fighter_recharge_deposit'])
const FIGHTER_MINE_PAGE_KEYS = new Set([
'fighter_order_pending', 'fighter_order_settling', 'fighter_order_done', 'fighter_order_closed',
'fighter_trade_commission', 'fighter_trade_withdraw', 'fighter_trade_settle', 'fighter_trade_deduct', 'fighter_trade_recharge',
'fighter_invite_subordinate', 'fighter_invite_poster', 'fighter_invite_code',
'fighter_kuaishou_cash', 'fighter_follow_kuaishou',
'fighter_complaint_record', 'fighter_mine_punish',
'fighter_mine_copy', 'fighter_mine_edit', 'fighter_mine_qiepian', 'fighter_mine_penalty_tip',
])
function mergeFixedIcons(metaList, fromApi) {
const map = new Map((fromApi || []).map((i) => [i.icon_key, i]))
return metaList.map((meta) => {
const row = map.get(meta.icon_key)
return {
icon_key: meta.icon_key,
label: row?.label || meta.label,
image_path: row?.image_path || '',
is_uploaded: !!row?.is_uploaded,
id: row?.id ?? null,
description: row?.description || '',
is_active: row?.is_active ?? true,
}
})
}
const posterIcons = computed(() => icons.value.filter((i) => POSTER_KEYS.has(i.icon_key)))
const funcIcons = computed(() => icons.value.filter((i) => !POSTER_KEYS.has(i.icon_key)))
const cuserMineIcons = computed(() => icons.value.filter((i) => CUSER_MINE_KEYS.has(i.icon_key)))
const merchantIcons = computed(() => icons.value.filter((i) => i.icon_key.startsWith('merchant_')))
const grabCardBgIcons = computed(() => mergeFixedIcons(GRAB_CARD_BG_META, icons.value))
const fighterPageBgIcons = computed(() => mergeFixedIcons(FIGHTER_PAGE_BG_META, icons.value))
const fighterBannerIcons = computed(() => icons.value.filter((i) => FIGHTER_BANNER_KEYS.has(i.icon_key)))
const fighterMinePageIcons = computed(() => icons.value.filter((i) => FIGHTER_MINE_PAGE_KEYS.has(i.icon_key)))
const fighterMoreFuncIcons = computed(() => icons.value.filter((i) => {
const k = i.icon_key
return k.startsWith('fighter_mine_') && !FIGHTER_MINE_PAGE_KEYS.has(k)
}))
const fighterRechargeIcons = computed(() => icons.value.filter((i) => {
const k = i.icon_key
if (FIGHTER_BANNER_KEYS.has(k) || FIGHTER_PAGE_BG_KEYS.has(k)) return false
return k.startsWith('fighter_recharge_') || k.startsWith('fighter_deposit_')
}))
const commonIcons = computed(() => icons.value.filter((i) => COMMON_KEYS.has(i.icon_key)))
const uploadingKey = ref('')
const pindao = ref({ channel_no: '', title: '频道' })
const pindaoImages = ref([])
@@ -121,6 +375,13 @@ function fullUrl(path) {
return base + (path.startsWith('/') ? path : path)
}
/** 未实际上传的配置型大图不展示默认路径(会 404 */
function previewUrl(item) {
if (!item) return ''
if (CONFIG_ONLY_PREVIEW_KEYS.has(item.icon_key) && !item.is_uploaded) return ''
return fullUrl(item.image_path)
}
async function loadIcons() {
const res = await request.post(JITUAN_API.miniappIconList, { phone: username.value })
if (res.code === 0) {
@@ -147,6 +408,10 @@ async function loadPindao() {
async function uploadIcon(file, iconKey) {
if (!file?.raw) return
if (!iconKey) {
ElMessage.error('缺少 icon_key')
return
}
uploadingKey.value = iconKey
try {
const fd = new FormData()
@@ -154,17 +419,17 @@ async function uploadIcon(file, iconKey) {
fd.append('file', file.raw)
fd.append('icon_key', iconKey)
fd.append('action', 'upload')
const res = await request.post(JITUAN_API.miniappIconManage, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
// 不要手动设 Content-Type否则缺 boundary后端可能读不到 icon_key
const res = await request.post(JITUAN_API.miniappIconManage, fd)
if (res.code === 0) {
ElMessage.success('上传成功')
await loadIcons()
} else {
ElMessage.error(res.msg || '上传失败')
ElMessage.error(res.msg || `上传失败(${iconKey})`)
}
} catch {
ElMessage.error('上传失败')
} catch (e) {
const msg = e?.response?.data?.msg || e?.message || '上传失败'
ElMessage.error(`${msg} [${iconKey}]`)
} finally {
uploadingKey.value = ''
}
@@ -247,6 +512,26 @@ onMounted(async () => {
.icon-key { font-size: 11px; color: #999; margin-bottom: 8px; }
.icon-preview { width: 64px; height: 64px; margin: 8px auto; }
.poster-preview { width: 100%; max-width: 200px; height: 120px; margin: 8px auto; border-radius: 6px; }
.empty-preview {
width: 100%;
max-width: 200px;
height: 120px;
margin: 8px auto;
border-radius: 6px;
border: 1px dashed #d0d7de;
background: #f6f8fa;
color: #8b949e;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.empty-preview-sm {
width: 64px;
height: 64px;
max-width: 64px;
font-size: 11px;
}
.lunbo-grid {
display: flex;
flex-wrap: wrap;

View File

@@ -0,0 +1,271 @@
<template>
<div class="rank-reward-page">
<el-alert
v-if="isAllClubScope"
type="warning"
:closable="false"
show-icon
title="排行榜奖励需在具体俱乐部视图下配置,请切换顶栏俱乐部。"
class="scope-alert"
/>
<ClubScopeHint v-else />
<div v-if="!hasPermission" class="no-permission">
<div class="no-permission-icon">🔒</div>
<div>无排行榜奖励配置权限phbj666 / 8080a</div>
</div>
<div v-else>
<div class="header-bar">
<h2>排行榜奖励配置</h2>
<p class="desc">按俱乐部分奖结算周期昨日 / 上周 / 上月可设置活动起止日期超出活动期不可结算与领取</p>
</div>
<el-tabs v-model="activeShenfen" @tab-change="loadSchemes">
<el-tab-pane label="接单员" name="dashou" />
<el-tab-pane label="管事" name="guanshi" />
<el-tab-pane label="组长" name="zuzhang" />
<el-tab-pane label="商家" name="shangjia" />
</el-tabs>
<el-tabs v-model="activePeriod" type="card" @tab-change="selectScheme">
<el-tab-pane label="日榜(昨日)" name="day" />
<el-tab-pane label="周榜(上周)" name="week" />
<el-tab-pane label="月榜(上月)" name="month" />
</el-tabs>
<el-card v-loading="loading">
<el-form :model="form" label-width="120px" :disabled="isAllClubScope">
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="如:星阙接单员周榜奖励" />
</el-form-item>
<el-form-item label="排序指标">
<el-select v-model="form.sort_field" style="width: 280px">
<el-option v-for="o in sortOptions" :key="o.key" :label="o.label" :value="o.key" />
</el-select>
</el-form-item>
<el-form-item label="最低指标">
<el-input-number v-model="form.min_metric" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="领取有效天">
<el-input-number v-model="form.claim_days" :min="1" :max="365" />
</el-form-item>
<el-form-item label="活动开始">
<el-date-picker v-model="form.activity_start" type="date" value-format="YYYY-MM-DD" placeholder="如 2025-01-01" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="活动结束">
<el-date-picker v-model="form.activity_end" type="date" value-format="YYYY-MM-DD" placeholder="如 2026-12-31" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="规则说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<div class="tier-toolbar">
<span>奖励档位</span>
<el-button size="small" :disabled="isAllClubScope" @click="addTier">+ 添加档位</el-button>
</div>
<el-table :data="form.tiers" border size="small">
<el-table-column label="名次起" width="100">
<template #default="{ row }"><el-input-number v-model="row.rank_from" :min="1" size="small" /></template>
</el-table-column>
<el-table-column label="名次止" width="100">
<template #default="{ row }"><el-input-number v-model="row.rank_to" :min="1" size="small" /></template>
</el-table-column>
<el-table-column label="奖金(元)" width="120">
<template #default="{ row }"><el-input-number v-model="row.reward_amount" :min="0" :precision="2" size="small" /></template>
</el-table-column>
<el-table-column label="标签">
<template #default="{ row }"><el-input v-model="row.label" size="small" placeholder="冠军" /></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button type="danger" link :disabled="isAllClubScope" @click="form.tiers.splice($index, 1)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="actions">
<el-button type="primary" :loading="saving" :disabled="isAllClubScope" @click="save">保存配置</el-button>
</div>
</el-card>
<el-card class="claims-card" header="领取明细最近100条">
<el-table :data="claims" border size="small" v-loading="claimsLoading">
<el-table-column prop="yonghuid" label="用户ID" width="100" />
<el-table-column prop="shenfen" label="身份" width="90" />
<el-table-column prop="mingci" label="名次" width="70" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="status_text" label="状态" width="90" />
<el-table-column prop="claimed_at" label="领取时间" />
</el-table>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { getAdminClubScope } from '@/utils/club-context'
const username = ref(localStorage.getItem('username') || '')
const hasPermission = ref(true)
const loading = ref(false)
const saving = ref(false)
const claimsLoading = ref(false)
const schemes = ref([])
const claims = ref([])
const activeShenfen = ref('dashou')
const activePeriod = ref('week')
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const SORT_OPTIONS = {
dashou: [
{ key: 'chengjiao_zongliang', label: '成交量' },
{ key: 'chengjiao_zonge', label: '成交总额' },
{ key: 'jiedan_zongliang', label: '接单量' },
{ key: 'jiedan_zonge', label: '接单总额' },
],
guanshi: [
{ key: 'chongzhi_dashou_shu', label: '有效打手数' },
{ key: 'shouru_zonge', label: '收益总额' },
{ key: 'yaoqing_dashou_shu', label: '邀请打手数' },
],
zuzhang: [
{ key: 'yaoqing_guanshi_shu', label: '邀请管事数' },
{ key: 'shouru_zonge', label: '收入总额' },
{ key: 'fenyong_jine', label: '分佣金额' },
],
shangjia: [
{ key: 'jiesuan_jine', label: '结算金额' },
{ key: 'jiesuan_dingdan_shu', label: '结算单量' },
{ key: 'paifa_jine', label: '派单流水' },
],
}
const sortOptions = computed(() => SORT_OPTIONS[activeShenfen.value] || [])
const form = reactive({
enabled: false,
title: '',
sort_field: 'chengjiao_zongliang',
min_metric: 0,
claim_days: 30,
activity_start: '',
activity_end: '',
description: '',
tiers: [{ rank_from: 1, rank_to: 1, reward_amount: 0, label: '冠军' }],
})
const loadSchemes = async () => {
loading.value = true
try {
const res = await request.post('/houtai/phbjhq', { username: username.value })
if (res.code === 403) {
hasPermission.value = false
return
}
if (res.code === 0) {
schemes.value = res.data.schemes || []
selectScheme()
}
} finally {
loading.value = false
}
}
const selectScheme = () => {
const hit = schemes.value.find(
(s) => s.shenfen === activeShenfen.value && s.period_type === activePeriod.value,
)
const defaults = SORT_OPTIONS[activeShenfen.value]?.[0]?.key || 'chengjiao_zongliang'
if (hit) {
Object.assign(form, {
enabled: hit.enabled,
title: hit.title || '',
sort_field: hit.sort_field || defaults,
min_metric: hit.min_metric || 0,
claim_days: hit.claim_days || 30,
activity_start: hit.activity_start || '',
activity_end: hit.activity_end || '',
description: hit.description || '',
tiers: hit.tiers?.length
? hit.tiers.map((t) => ({ ...t, reward_amount: Number(t.reward_amount) }))
: [{ rank_from: 1, rank_to: 1, reward_amount: 0, label: '冠军' }],
})
} else {
Object.assign(form, {
enabled: false,
title: '',
sort_field: defaults,
min_metric: 0,
claim_days: 30,
activity_start: '',
activity_end: '',
description: '',
tiers: [{ rank_from: 1, rank_to: 1, reward_amount: 0, label: '冠军' }],
})
}
}
const addTier = () => {
form.tiers.push({ rank_from: 1, rank_to: 3, reward_amount: 0, label: '' })
}
const save = async () => {
if (!form.tiers.length) {
ElMessage.warning('请配置奖励档位')
return
}
saving.value = true
try {
const res = await request.post('/houtai/phbjbc', {
username: username.value,
shenfen: activeShenfen.value,
period_type: activePeriod.value,
...form,
})
if (res.code === 0) {
ElMessage.success('保存成功')
await loadSchemes()
} else {
ElMessage.error(res.msg || '保存失败')
}
} finally {
saving.value = false
}
}
const loadClaims = async () => {
claimsLoading.value = true
try {
const res = await request.post('/houtai/phbjjl', { username: username.value })
if (res.code === 0) claims.value = res.data.list || []
} finally {
claimsLoading.value = false
}
}
onMounted(() => {
loadSchemes()
loadClaims()
})
</script>
<style scoped>
.rank-reward-page { padding: 16px; }
.scope-alert { margin-bottom: 12px; }
.header-bar { margin-bottom: 16px; }
.header-bar h2 { margin: 0 0 8px; }
.desc { margin: 0; color: #888; font-size: 13px; }
.tier-toolbar { display: flex; justify-content: space-between; margin: 16px 0 8px; }
.actions { margin-top: 16px; }
.claims-card { margin-top: 20px; }
.no-permission { text-align: center; padding: 80px 20px; }
</style>

View File

@@ -143,11 +143,12 @@ import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { getMenuAccess, hasMerchantOrderPerm, ensureMenuAccess, applyMenuAccessPayload, JITUAN_API } from '@/utils/club-context'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const hasPermission = ref(true)
const hasPermission = ref(false)
const PAGE_SIZE = 20
const pageNum = ref(1)
@@ -202,9 +203,7 @@ const getFullImageUrl = (relativeUrl) => {
// 订单类型接口(未改动)
const fetchOrderTypes = async () => {
try {
const res = await request.post('/yonghu/kfhqptddlx', {
phone: localStorage.getItem('username')
})
const res = await request.post('/yonghu/kfhqptddlx', {}, { skipErrorToast: true })
if (res.code === 0) {
orderTypes.value = res.data || []
if (!currentType.value) currentType.value = ''
@@ -216,13 +215,14 @@ const fetchOrderTypes = async () => {
}
}
// 商家订单列表接口(改动的接口)
// 商家订单列表:以 API 权限为准,不用前端菜单缓存拦截
const fetchOrders = async () => {
loading.value = true
try {
const username = localStorage.getItem('username')
const phone = localStorage.getItem('username')
const params = {
username,
phone,
username: phone,
page: pageNum.value,
pageSize: pageSize.value,
zhuangtai: currentStatus.value === 'all' ? undefined : currentStatus.value,
@@ -240,7 +240,7 @@ const fetchOrders = async () => {
}
Object.keys(params).forEach(key => params[key] === undefined && delete params[key])
const res = await request.post('/yonghu/kfhqsjddlb', params)
const res = await request.post('/yonghu/kfhqsjddlb', params, { skipErrorToast: true })
if (res.code === 0) {
const data = res.data
orderList.value = data.list || []
@@ -259,7 +259,7 @@ const fetchOrders = async () => {
}
} else if (res.code === 403) {
hasPermission.value = false
ElMessage.error(res.msg || '您无权访问此页面')
ElMessage.warning(res.msg || '当前俱乐部无商家订单权限')
} else {
ElMessage.error(res.msg || '获取订单列表失败')
}
@@ -361,7 +361,13 @@ const getStatusType = (code) => {
watch(() => route.query, () => { restoreFromQuery(); fetchOrders() }, { deep: true })
onMounted(() => {
onMounted(async () => {
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess, { skipErrorToast: true })
return res.code === 0 ? res.data : null
}, { force: true })
hasPermission.value = hasMerchantOrderPerm(getMenuAccess())
if (!hasPermission.value) return
restoreFromQuery()
fetchOrderTypes()
fetchOrders()

View File

@@ -148,11 +148,12 @@ import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { getMenuAccess, hasPermCode } from '@/utils/club-context'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const hasPermission = ref(true)
const hasPermission = ref(false)
const PAGE_SIZE = 20
const pageNum = ref(1)
@@ -206,9 +207,7 @@ const getFullImageUrl = (relativeUrl) => {
// 获取订单类型(未改动,仍用 phone
const fetchOrderTypes = async () => {
try {
const res = await request.post('/yonghu/kfhqptddlx', {
phone: localStorage.getItem('username')
})
const res = await request.post('/yonghu/kfhqptddlx', {}, { skipErrorToast: true })
if (res.code === 0) {
orderTypes.value = res.data || []
if (!currentType.value) currentType.value = ''
@@ -363,6 +362,8 @@ const getStatusType = (code) => {
watch(() => route.query, () => { restoreFromQuery(); fetchOrders() }, { deep: true })
onMounted(() => {
hasPermission.value = hasPermCode(getMenuAccess(), '002ab')
if (!hasPermission.value) return
restoreFromQuery()
fetchOrderTypes()
fetchOrders()

View File

@@ -0,0 +1,522 @@
<template>
<div class="fake-order-page">
<div v-if="!hasPermission" class="no-permission">
<div class="no-permission-icon">🔒</div>
<div class="no-permission-text">您无权访问此页面</div>
<div class="no-permission-desc">需要商品管理权限2200a</div>
</div>
<div v-else-if="isAllClubScope" class="scope-block">
<el-alert
type="warning"
:closable="false"
show-icon
title="请切换到具体俱乐部"
description="假单按俱乐部独立配置。请先在顶栏选择具体小程序/俱乐部,不要用「集团汇总」。"
/>
</div>
<div v-else>
<ClubScopeHint />
<el-alert
type="info"
:closable="false"
show-icon
class="tip"
title="假单说明"
description="无会员/低押金打手在抢单大厅仅能看到假单(不可真正接单)。按商品类型 Tab 展示:有什么类型就配什么类型的假单。所有全局商品类型均可绑定。"
/>
<div class="toolbar">
<el-select
v-model="filterLeixingId"
clearable
filterable
placeholder="全部商品类型"
class="dark-select"
style="width: 220px"
@change="search"
>
<el-option
v-for="opt in leixingOptions"
:key="opt.id"
:label="`${opt.jieshao || '类型'} (ID ${opt.id})`"
:value="opt.id"
/>
</el-select>
<el-button type="primary" @click="openCreate">新建假单</el-button>
<el-button @click="loadList" :loading="loading">刷新</el-button>
</div>
<div class="table-wrap" v-loading="loading">
<el-table :data="list" border class="fake-order-table" row-key="order_id">
<el-table-column label="头像" width="72">
<template #default="{ row }">
<el-image :src="fullUrl(row.paidan_touxiang)" fit="cover" class="avatar-thumb" />
</template>
</el-table-column>
<el-table-column prop="order_id" label="订单ID" min-width="170" show-overflow-tooltip />
<el-table-column label="商品类型" min-width="130">
<template #default="{ row }">
<div>{{ row.leixing_name }}</div>
<div class="sub">ID {{ row.leixing_id }}</div>
</template>
</el-table-column>
<el-table-column label="价格(打手分红)" width="120">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column prop="platform_label" label="平台" width="90" />
<el-table-column prop="shangjia_nicheng" label="商家名" min-width="100" show-overflow-tooltip />
<el-table-column label="优质" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_youzhi_shangjia" size="small" type="warning"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="jieshao" label="介绍" min-width="160" show-overflow-tooltip />
<el-table-column prop="beizhu" label="备注" min-width="100" show-overflow-tooltip />
<el-table-column prop="sort_order" label="排序" width="70" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="confirmDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="onSizeChange"
/>
</div>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑假单' : '新建假单'"
width="680px"
destroy-on-close
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="商品类型" prop="leixing_id">
<el-select v-model="form.leixing_id" filterable placeholder="选择商品类型" style="width: 100%">
<el-option
v-for="opt in leixingOptions"
:key="opt.id"
:label="`${opt.jieshao || '类型'} (ID ${opt.id})`"
:value="opt.id"
/>
</el-select>
</el-form-item>
<el-form-item label="价格(元)" prop="amount">
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="发单平台" prop="fadan_pingtai">
<el-radio-group v-model="form.fadan_pingtai">
<el-radio :value="1">平台发单</el-radio>
<el-radio :value="2">商家发单</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.fadan_pingtai === 2">
<el-form-item label="商家名称">
<el-input v-model="form.shangjia_nicheng" maxlength="50" placeholder="商家发单时展示" />
</el-form-item>
<el-form-item label="优质商家">
<el-switch v-model="form.is_youzhi_shangjia" />
</el-form-item>
</template>
<el-form-item label="订单介绍">
<el-input v-model="form.jieshao" type="textarea" :rows="4" placeholder="抢单大厅展示的介绍文案" />
</el-form-item>
<el-form-item label="订单备注">
<el-input v-model="form.beizhu" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="派单用户ID">
<el-input v-model="form.paidan_yonghuid" maxlength="32" placeholder="选填,展示用" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" style="width: 100%" />
<span class="hint">数值越大越靠前打乱前基准顺序</span>
</el-form-item>
<el-form-item label="展示头像">
<div class="avatar-upload">
<el-image v-if="form.paidan_touxiang" :src="fullUrl(form.paidan_touxiang)" fit="cover" class="avatar-preview" />
<el-upload
v-if="isEdit"
action="#"
:auto-upload="false"
:show-file-list="false"
accept="image/jpeg,image/png,image/jpg,image/webp"
:on-change="uploadAvatar"
>
<el-button size="small" type="primary" :loading="uploading">上传头像</el-button>
</el-upload>
<span v-else-if="!form.paidan_touxiang" class="hint">创建后可编辑并上传</span>
</div>
</el-form-item>
<el-form-item v-if="isEdit" label="订单ID">
<el-input :model-value="form.order_id" disabled />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { JITUAN_API, getAdminClubScope } from '@/utils/club-context'
const username = ref(localStorage.getItem('username') || '')
const hasPermission = ref(true)
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const leixingOptions = ref([])
const filterLeixingId = ref('')
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const ossBaseUrl = ref(window.$ossURL || '')
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const defaultForm = () => ({
order_id: '',
leixing_id: '',
amount: 0,
fadan_pingtai: 1,
shangjia_nicheng: '',
is_youzhi_shangjia: false,
jieshao: '',
beizhu: '',
paidan_yonghuid: '',
paidan_touxiang: '',
sort_order: 0,
})
const form = ref(defaultForm())
const rules = {
leixing_id: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
amount: [{ required: true, message: '请输入价格', trigger: 'blur' }],
fadan_pingtai: [{ required: true, message: '请选择平台', trigger: 'change' }],
}
function fullUrl(path) {
if (!path) return ''
if (path.startsWith('http')) return path
return ossBaseUrl.value + path
}
function formatMoney(val) {
if (val === undefined || val === null || val === '') return '0.00'
return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadList() {
if (isAllClubScope.value) return
loading.value = true
try {
const res = await request.post(JITUAN_API.fakeGrabOrderList, {
username: username.value,
page: page.value,
page_size: pageSize.value,
leixing_id: filterLeixingId.value || undefined,
})
if (res.code === 403) {
hasPermission.value = false
return
}
if (res.code === 0) {
const data = res.data || {}
if (data.scope_error) {
ElMessage.warning(data.scope_error)
list.value = []
return
}
list.value = data.list || []
total.value = data.total || 0
leixingOptions.value = data.leixing_options || []
} else {
ElMessage.error(res.msg || '加载失败')
}
} catch (e) {
console.error(e)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
loadList()
}
function onSizeChange() {
page.value = 1
loadList()
}
function openCreate() {
isEdit.value = false
form.value = defaultForm()
if (filterLeixingId.value) {
form.value.leixing_id = filterLeixingId.value
}
dialogVisible.value = true
}
function openEdit(row) {
isEdit.value = true
form.value = {
order_id: row.order_id,
leixing_id: row.leixing_id,
amount: Number(row.amount || 0),
fadan_pingtai: row.fadan_pingtai || 1,
shangjia_nicheng: row.shangjia_nicheng || '',
is_youzhi_shangjia: Boolean(row.is_youzhi_shangjia),
jieshao: row.jieshao || '',
beizhu: row.beizhu || '',
paidan_yonghuid: row.paidan_yonghuid || '',
paidan_touxiang: row.paidan_touxiang || '',
sort_order: row.sort_order || 0,
}
dialogVisible.value = true
}
function resetForm() {
form.value = defaultForm()
formRef.value?.resetFields()
}
async function submitForm() {
try {
await formRef.value?.validate()
} catch {
return
}
if (form.value.amount < 0) {
ElMessage.error('价格不能为负')
return
}
saving.value = true
try {
const payload = {
username: username.value,
action: isEdit.value ? 'update' : 'create',
leixing_id: form.value.leixing_id,
amount: form.value.amount,
fadan_pingtai: form.value.fadan_pingtai,
jieshao: form.value.jieshao,
beizhu: form.value.beizhu,
paidan_yonghuid: form.value.paidan_yonghuid,
sort_order: form.value.sort_order,
}
if (form.value.fadan_pingtai === 2) {
payload.shangjia_nicheng = form.value.shangjia_nicheng
payload.is_youzhi_shangjia = form.value.is_youzhi_shangjia
}
if (isEdit.value) {
payload.order_id = form.value.order_id
}
const res = await request.post(JITUAN_API.fakeGrabOrderManage, payload)
if (res.code === 0) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadList()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
console.error(e)
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function uploadAvatar(file) {
if (!file?.raw || !form.value.order_id) return
uploading.value = true
try {
const fd = new FormData()
fd.append('username', username.value)
fd.append('order_id', form.value.order_id)
fd.append('file', file.raw)
const res = await request.post(JITUAN_API.fakeGrabOrderAvatar, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.code === 0) {
form.value.paidan_touxiang = res.data?.paidan_touxiang || ''
ElMessage.success('头像已更新')
await loadList()
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除假单 ${row.order_id} 吗?`, '确认删除', { type: 'warning' })
} catch {
return
}
try {
const res = await request.post(JITUAN_API.fakeGrabOrderManage, {
username: username.value,
action: 'delete',
order_id: row.order_id,
})
if (res.code === 0) {
ElMessage.success('已删除')
await loadList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch {
ElMessage.error('删除失败')
}
}
onMounted(loadList)
</script>
<style scoped>
.fake-order-page {
padding: 20px;
min-height: 100vh;
color: #c0e0ff;
}
.tip { margin-bottom: 16px; }
.scope-block { padding: 24px 0; }
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.table-wrap {
background: rgba(18, 25, 35, 0.85);
border: 1px solid rgba(64, 158, 255, 0.15);
border-radius: 12px;
padding: 16px;
}
.avatar-thumb {
width: 40px;
height: 40px;
border-radius: 6px;
border: 1px solid rgba(64, 158, 255, 0.2);
}
.avatar-preview {
width: 64px;
height: 64px;
border-radius: 8px;
margin-right: 12px;
}
.avatar-upload {
display: flex;
align-items: center;
gap: 8px;
}
.sub {
font-size: 12px;
color: #9ec5ff;
}
.hint {
margin-left: 8px;
font-size: 12px;
color: #8fb8e8;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.no-permission {
text-align: center;
padding: 80px 20px;
}
.no-permission-icon { font-size: 48px; }
.no-permission-text { font-size: 20px; color: #6cc3e8; margin-top: 12px; }
.no-permission-desc { color: #8fb8e8; margin-top: 8px; }
.fake-order-table :deep(.el-table) {
--el-table-bg-color: transparent;
--el-table-header-bg-color: rgba(20, 30, 45, 0.95);
--el-table-text-color: #ffffff;
--el-table-header-text-color: #9ec5ff;
--el-table-border-color: rgba(64, 158, 255, 0.12);
background: transparent;
}
.fake-order-table :deep(.el-table th.el-table__cell),
.fake-order-table :deep(.el-table td.el-table__cell) {
background: rgba(12, 18, 28, 0.55) !important;
color: #ffffff !important;
}
.fake-order-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: rgba(64, 158, 255, 0.1) !important;
}
.toolbar :deep(.el-input__wrapper),
.toolbar :deep(.el-select .el-input__wrapper),
:deep(.el-dialog .el-input__wrapper),
:deep(.el-dialog .el-select .el-input__wrapper),
:deep(.el-dialog .el-input-number .el-input__wrapper),
:deep(.el-dialog .el-textarea__inner) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
box-shadow: none !important;
}
:deep(.el-dialog .el-input__inner),
:deep(.el-dialog .el-textarea__inner),
:deep(.el-dialog .el-input-number__input) {
color: #ffffff !important;
}
:deep(.el-dialog) {
background: rgba(18, 25, 35, 0.96);
border: 1px solid #2a6f8f;
}
:deep(.el-dialog__title) {
color: #6cc3e8;
}
:deep(.el-form-item__label) {
color: #9ec5ff !important;
}
.pager :deep(.el-pagination .el-select .el-input__wrapper),
.pager :deep(.el-pagination .btn-prev),
.pager :deep(.el-pagination .btn-next),
.pager :deep(.el-pagination .el-pager li) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
color: #9ec5ff !important;
}
.pager :deep(.el-pagination__total) {
color: #9ec5ff !important;
}
</style>

View File

@@ -7,24 +7,41 @@
<div class="no-permission-desc">请联系管理员开通权限</div>
</div>
<div v-else-if="!isAllClubScope" class="scope-block">
<div v-else>
<el-alert
type="warning"
v-if="isAllClubScope"
type="info"
:closable="false"
show-icon
title="当前为具体小程序视图"
description="商品为集团全局数据,不可在此视图修改。请切换顶栏为「集团汇总(全部子公司)」后管理商品;本俱乐部抢单展示请在「小程序配置 → 抢单商品类型」中上架/换图标。"
class="scope-tip"
title="当前为集团汇总视图"
description="此处可看全部俱乐部专区/商品。新增商品会写入当前请求头俱乐部;「只看审核」开关请切到具体小程序后再改。类型定义仍在「商品类型专区管理」;各小程序上架类型请用「抢单商品类型」。"
/>
<el-alert
v-else
type="success"
:closable="false"
show-icon
class="scope-tip"
title="当前为具体小程序视图"
description="专区与商品按本俱乐部隔离。类型为集团共享定义;本俱乐部抢单展示请在「小程序配置 → 抢单商品类型」上架。下方可设置未绑店用户看审核商品还是正常商品。"
/>
<el-button type="primary" link @click="$router.push('/miniapp/leixing')">前往抢单商品类型</el-button>
</div>
<div v-else>
<!-- 顶部操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="openAddDialog" :icon="Plus">添加商品</el-button>
<div class="audit-switch">
<span>显示审核专用</span>
<el-switch v-model="showAudit" @change="onAuditSwitchChange" />
<template v-if="!isAllClubScope">
<span class="switch-gap">只看审核商品</span>
<el-switch
v-model="zhiKanShenhe"
:loading="zhiKanLoading"
@change="onZhiKanChange"
/>
<span class="switch-hint">未绑店且无打手等身份时生效</span>
</template>
</div>
</div>
@@ -288,6 +305,9 @@ const zoneList = ref([]) // 同上
const memberList = ref([])
const showAudit = ref(false)
const zhiKanShenhe = ref(true)
const zhiKanLoading = ref(false)
const zhiKanReady = ref(false) // 避免初次赋值触发 change
// 展示用的类型列表:按 paixu 降序(大的在前),并过滤审核状态
const typeListDisplay = computed(() => {
let list = [...typeList.value]
@@ -775,15 +795,15 @@ const handleDeleteProduct = async (productId) => {
// ---------- 基础数据获取 ----------
const fetchBaseData = async () => {
if (!isAllClubScope.value) return
try {
const res = await request.post('/houtai/htsphqjcsx', { username: username.value })
if (res.code === 0) {
typeList.value = res.data.type_list || []
zoneList.value = res.data.zone_list || []
memberList.value = res.data.member_list || []
// 注意:后端返回的 paixu 已经是倒序值(越大越靠前),我们不需要修改
// 展示时会通过计算属性按 paixu 降序排序,视觉上从上到下顺序正确
zhiKanReady.value = false
zhiKanShenhe.value = res.data.zhi_kan_shenhe !== false
nextTick(() => { zhiKanReady.value = true })
const firstNormal = typeListDisplay.value[0]
if (firstNormal) {
selectedTypeId.value = firstNormal.id
@@ -800,6 +820,28 @@ const fetchBaseData = async () => {
}
}
const onZhiKanChange = async (val) => {
if (!zhiKanReady.value || isAllClubScope.value) return
zhiKanLoading.value = true
try {
const res = await request.post('/houtai/htxgzhikan', {
username: username.value,
zhi_kan_shenhe: val,
})
if (res.code === 0) {
ElMessage.success(val ? '已开启:未绑店无身份用户只看审核商品' : '已关闭:未绑店用户看正常商品')
} else {
zhiKanShenhe.value = !val
ElMessage.error(res.msg || '更新失败')
}
} catch (e) {
zhiKanShenhe.value = !val
ElMessage.error('更新失败')
} finally {
zhiKanLoading.value = false
}
}
const getFullUrl = (path) => {
if (!path) return ''
const ossUrl = window.$ossURL || ''
@@ -820,6 +862,7 @@ onMounted(() => {
<style scoped>
.scope-block { padding: 24px; }
.scope-tip { margin-bottom: 16px; }
/* 整体容器 */
.product-list-container {
padding: 20px;
@@ -837,10 +880,18 @@ onMounted(() => {
align-items: center;
gap: 12px;
background: rgba(0,0,0,0.4);
flex-wrap: wrap;
padding: 6px 16px;
border-radius: 20px;
backdrop-filter: blur(4px);
}
.switch-gap {
margin-left: 8px;
}
.switch-hint {
font-size: 12px;
opacity: 0.7;
}
.types-zones-section {
display: flex;
gap: 24px;

View File

@@ -0,0 +1,552 @@
<template>
<div class="zhuanpan-page">
<el-alert
type="info"
:closable="false"
show-icon
class="tip"
title="转盘 = 一种商品"
description="在此新建转盘商品(名称/价格/类型),再配奖池概率与库存。控奖可指定某用户必中某奖。按顶部俱乐部隔离,不影响其他俱乐部。"
/>
<div class="toolbar">
<el-button type="primary" @click="openCreate()">新建转盘商品</el-button>
<el-button @click="reloadAll">刷新</el-button>
</div>
<el-table :data="huodongList" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="biaoqian" label="商品名称" min-width="140" />
<el-table-column prop="jiage" label="购票价" width="100" />
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.qiyong ? 'success' : 'info'">{{ row.qiyong ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="beizhu" label="备注" min-width="120" />
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" @click="openJiangpin(row)">奖池</el-button>
<el-button link type="primary" @click="openKongjiang(row)">控奖</el-button>
<el-button link type="warning" @click="toggleHuodong(row)">{{ row.qiyong ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建转盘商品 -->
<el-dialog v-model="createVisible" title="新建转盘商品" width="520px">
<el-form label-width="110px">
<el-form-item label="商品名称" required>
<el-input v-model="createForm.biaoqian" maxlength="45" placeholder="用户看到的购票商品名" />
</el-form-item>
<el-form-item label="购票价格" required>
<el-input-number v-model="createForm.jiage" :min="0.01" :step="1" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="商品类型" required>
<el-select v-model="createForm.leixing_id" filterable placeholder="选择类型" style="width: 100%">
<el-option v-for="t in leixingList" :key="t.id" :label="t.jieshao" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="所属专区">
<el-select v-model="createForm.zhuanqu_id" clearable filterable placeholder="可选" style="width: 100%">
<el-option
v-for="z in zhuanquFiltered"
:key="z.id"
:label="z.mingzi"
:value="z.id"
/>
</el-select>
</el-form-item>
<el-form-item label="商品图URL">
<el-input v-model="createForm.tupian_url" placeholder="相对路径或完整 URL" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.beizhu" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveCreate">创建</el-button>
</template>
</el-dialog>
<!-- 编辑 -->
<el-dialog v-model="editVisible" title="编辑转盘商品" width="520px">
<el-form label-width="110px">
<el-form-item label="商品名称">
<el-input v-model="editForm.biaoqian" maxlength="45" />
</el-form-item>
<el-form-item label="展示名">
<el-input v-model="editForm.mingzi" maxlength="64" />
</el-form-item>
<el-form-item label="购票价格">
<el-input-number v-model="editForm.jiage" :min="0.01" :step="1" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="商品图URL">
<el-input v-model="editForm.tupian_url" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.beizhu" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="editForm.qiyong" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 奖池 -->
<el-dialog v-model="jpVisible" :title="'奖池 - ' + (currentHd?.biaoqian || currentHd?.mingzi || '')" width="860px">
<div class="toolbar">
<el-button type="primary" size="small" @click="openJpEdit()">添加奖品</el-button>
<el-button size="small" @click="loadJiangpin">刷新</el-button>
</div>
<el-table :data="jiangpinList" v-loading="jpLoading" border size="small">
<el-table-column prop="paixu" label="格子序" width="70" />
<el-table-column prop="mingzi" label="名称" min-width="110" />
<el-table-column prop="quanzhong" label="权重" width="70" />
<el-table-column label="库存" width="90">
<template #default="{ row }">{{ row.kucun === -1 ? '不限' : row.kucun }}</template>
</el-table-column>
<el-table-column label="履约商品" min-width="140">
<template #default="{ row }">
{{ row.shi_xiexie ? '—' : (row.shangpin_biaoqian || (row.shangpin_id ? '#' + row.shangpin_id : '未绑')) }}
</template>
</el-table-column>
<el-table-column label="谢谢参与" width="90">
<template #default="{ row }">{{ row.shi_xiexie ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button link type="primary" @click="openJpEdit(row)">编辑</el-button>
<el-button link type="danger" @click="delJiangpin(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="jpEditVisible" :title="jpForm.id ? '编辑奖品' : '添加奖品'" width="520px">
<el-form label-width="110px">
<el-form-item label="名称"><el-input v-model="jpForm.mingzi" /></el-form-item>
<el-form-item label="格子序号"><el-input-number v-model="jpForm.paixu" :min="0" :max="50" /></el-form-item>
<el-form-item label="中奖权重"><el-input-number v-model="jpForm.quanzhong" :min="0" /></el-form-item>
<el-form-item label="库存">
<el-input-number v-model="jpForm.kucun" :min="-1" />
<div class="form-tip">-1 = 不限0 = 普通抽奖抽不中控奖仍可强制</div>
</el-form-item>
<el-form-item label="谢谢参与"><el-switch v-model="jpForm.shi_xiexie" /></el-form-item>
<el-form-item v-if="!jpForm.shi_xiexie" label="履约商品">
<el-select
v-model="jpForm.shangpin_id"
clearable
filterable
placeholder="中奖后进抢池的商品(可选)"
style="width: 100%"
>
<el-option
v-for="g in goodsList"
:key="g.id"
:label="`${g.biaoqian}(¥${g.jiage}`"
:value="g.id"
/>
</el-select>
</el-form-item>
<el-form-item label="图标URL"><el-input v-model="jpForm.tupian_url" placeholder="相对路径或完整URL" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="jpEditVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveJiangpin">保存</el-button>
</template>
</el-dialog>
<!-- 控奖 -->
<el-dialog v-model="kjVisible" :title="'控奖 - ' + (currentHd?.biaoqian || '')" width="760px">
<div class="toolbar">
<el-button type="primary" size="small" @click="openKjEdit()">指定必中</el-button>
<el-button size="small" @click="loadKongjiang">刷新</el-button>
</div>
<el-table :data="kongjiangList" v-loading="kjLoading" border size="small">
<el-table-column prop="yonghuid" label="用户UID" min-width="120" />
<el-table-column prop="jiangpin_mingzi" label="必中奖品" min-width="120" />
<el-table-column prop="cishu" label="剩余次数" width="90" />
<el-table-column label="启用" width="80">
<template #default="{ row }">{{ row.qiyong ? '是' : '否' }}</template>
</el-table-column>
<el-table-column prop="beizhu" label="备注" min-width="100" />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button link type="primary" @click="openKjEdit(row)">编辑</el-button>
<el-button link type="danger" @click="delKongjiang(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="kjEditVisible" :title="kjForm.id ? '编辑控奖' : '指定必中'" width="480px">
<el-form label-width="110px">
<el-form-item label="用户UID" required>
<el-input v-model="kjForm.yonghuid" placeholder="小程序用户 UID" />
</el-form-item>
<el-form-item label="必中奖品" required>
<el-select v-model="kjForm.jiangpin_id" filterable style="width: 100%">
<el-option v-for="p in jiangpinList" :key="p.id" :label="p.mingzi" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="次数">
<el-input-number v-model="kjForm.cishu" :min="1" />
</el-form-item>
<el-form-item label="启用"><el-switch v-model="kjForm.qiyong" /></el-form-item>
<el-form-item label="备注"><el-input v-model="kjForm.beizhu" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="kjEditVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveKongjiang">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const jpLoading = ref(false)
const kjLoading = ref(false)
const saving = ref(false)
const huodongList = ref([])
const jiangpinList = ref([])
const kongjiangList = ref([])
const leixingList = ref([])
const zhuanquList = ref([])
const goodsList = ref([])
const currentHd = ref(null)
const createVisible = ref(false)
const createForm = reactive({
biaoqian: '幸运转盘',
jiage: 9.9,
leixing_id: null,
zhuanqu_id: null,
tupian_url: '',
beizhu: '',
})
const editVisible = ref(false)
const editForm = reactive({
id: null, biaoqian: '', mingzi: '', jiage: 0, tupian_url: '', beizhu: '', qiyong: true,
})
const jpVisible = ref(false)
const jpEditVisible = ref(false)
const jpForm = reactive({
id: null, mingzi: '', paixu: 0, quanzhong: 1, kucun: -1,
shangpin_id: null, tupian_url: '', shi_xiexie: false,
})
const kjVisible = ref(false)
const kjEditVisible = ref(false)
const kjForm = reactive({
id: null, yonghuid: '', jiangpin_id: null, cishu: 1, qiyong: true, beizhu: '',
})
const zhuanquFiltered = computed(() => {
if (!createForm.leixing_id) return zhuanquList.value
return zhuanquList.value.filter((z) => !z.leixing_id || z.leixing_id === createForm.leixing_id)
})
async function loadMeta() {
const res = await request.post('/shangpin/adzhuanpanhd', { action: 'meta' })
if (res.code === 0) {
leixingList.value = res.data?.leixing || []
zhuanquList.value = res.data?.zhuanqu || []
goodsList.value = res.data?.goods || []
}
}
async function loadHuodong() {
loading.value = true
try {
const res = await request.post('/shangpin/adzhuanpanhd', { action: 'list' })
if (res.code === 0) huodongList.value = res.data || []
else ElMessage.error(res.msg || '加载失败')
} finally {
loading.value = false
}
}
async function reloadAll() {
await loadMeta()
await loadHuodong()
}
function openCreate() {
Object.assign(createForm, {
biaoqian: '幸运转盘',
jiage: 9.9,
leixing_id: leixingList.value[0]?.id || null,
zhuanqu_id: null,
tupian_url: '',
beizhu: '',
})
createVisible.value = true
}
async function saveCreate() {
if (!createForm.biaoqian) {
ElMessage.warning('请填写商品名称')
return
}
if (!createForm.leixing_id) {
ElMessage.warning('请选择商品类型')
return
}
saving.value = true
try {
const res = await request.post('/shangpin/adzhuanpanhd', {
action: 'create_product',
...createForm,
mingzi: createForm.biaoqian,
})
if (res.code === 0) {
ElMessage.success('已创建转盘商品')
createVisible.value = false
await loadHuodong()
} else ElMessage.error(res.msg || '创建失败')
} finally {
saving.value = false
}
}
function openEdit(row) {
Object.assign(editForm, {
id: row.id,
biaoqian: row.biaoqian || row.mingzi,
mingzi: row.mingzi,
jiage: Number(row.jiage) || 0,
tupian_url: row.tupian_url || '',
beizhu: row.beizhu || '',
qiyong: !!row.qiyong,
})
editVisible.value = true
}
async function saveEdit() {
saving.value = true
try {
const res = await request.post('/shangpin/adzhuanpanhd', {
action: 'save',
id: editForm.id,
biaoqian: editForm.biaoqian,
mingzi: editForm.mingzi,
jiage: editForm.jiage,
tupian_url: editForm.tupian_url,
beizhu: editForm.beizhu,
qiyong: editForm.qiyong,
})
if (res.code === 0) {
ElMessage.success('已保存')
editVisible.value = false
loadHuodong()
} else ElMessage.error(res.msg || '保存失败')
} finally {
saving.value = false
}
}
async function toggleHuodong(row) {
const res = await request.post('/shangpin/adzhuanpanhd', { action: 'toggle', id: row.id })
if (res.code === 0) {
ElMessage.success('已更新')
loadHuodong()
} else ElMessage.error(res.msg || '操作失败')
}
async function openJiangpin(row) {
currentHd.value = row
jpVisible.value = true
await loadMeta()
await loadJiangpin()
}
async function loadJiangpin() {
if (!currentHd.value) return
jpLoading.value = true
try {
const res = await request.post('/shangpin/adzhuanpanjp', {
action: 'list',
huodong_id: currentHd.value.id,
})
if (res.code === 0) jiangpinList.value = res.data || []
else ElMessage.error(res.msg || '加载失败')
} finally {
jpLoading.value = false
}
}
function openJpEdit(row) {
if (row) {
Object.assign(jpForm, {
id: row.id,
mingzi: row.mingzi,
paixu: row.paixu,
quanzhong: row.quanzhong,
kucun: row.kucun == null ? -1 : row.kucun,
shangpin_id: row.shangpin_id,
tupian_url: row.tupian_url || '',
shi_xiexie: !!row.shi_xiexie,
})
} else {
Object.assign(jpForm, {
id: null,
mingzi: '',
paixu: jiangpinList.value.length,
quanzhong: 1,
kucun: -1,
shangpin_id: null,
tupian_url: '',
shi_xiexie: false,
})
}
jpEditVisible.value = true
}
async function saveJiangpin() {
if (!currentHd.value) return
saving.value = true
try {
const res = await request.post('/shangpin/adzhuanpanjp', {
action: 'save',
huodong_id: currentHd.value.id,
id: jpForm.id,
mingzi: jpForm.mingzi,
paixu: jpForm.paixu,
quanzhong: jpForm.quanzhong,
kucun: jpForm.kucun,
shangpin_id: jpForm.shi_xiexie ? null : (jpForm.shangpin_id || null),
tupian_url: jpForm.tupian_url,
shi_xiexie: jpForm.shi_xiexie,
})
if (res.code === 0) {
ElMessage.success('已保存')
jpEditVisible.value = false
loadJiangpin()
} else ElMessage.error(res.msg || '保存失败')
} finally {
saving.value = false
}
}
async function delJiangpin(row) {
await ElMessageBox.confirm('确认删除该奖品?', '提示')
const res = await request.post('/shangpin/adzhuanpanjp', {
action: 'delete',
huodong_id: currentHd.value.id,
id: row.id,
})
if (res.code === 0) {
ElMessage.success('已删除')
loadJiangpin()
} else ElMessage.error(res.msg || '删除失败')
}
async function openKongjiang(row) {
currentHd.value = row
kjVisible.value = true
await loadJiangpin()
await loadKongjiang()
}
async function loadKongjiang() {
if (!currentHd.value) return
kjLoading.value = true
try {
const res = await request.post('/shangpin/adzhuanpankj', {
action: 'list',
huodong_id: currentHd.value.id,
})
if (res.code === 0) kongjiangList.value = res.data || []
else ElMessage.error(res.msg || '加载失败')
} finally {
kjLoading.value = false
}
}
function openKjEdit(row) {
if (row) {
Object.assign(kjForm, {
id: row.id,
yonghuid: row.yonghuid,
jiangpin_id: row.jiangpin_id,
cishu: row.cishu,
qiyong: !!row.qiyong,
beizhu: row.beizhu || '',
})
} else {
Object.assign(kjForm, {
id: null,
yonghuid: '',
jiangpin_id: jiangpinList.value[0]?.id || null,
cishu: 1,
qiyong: true,
beizhu: '',
})
}
kjEditVisible.value = true
}
async function saveKongjiang() {
if (!currentHd.value) return
if (!kjForm.yonghuid || !kjForm.jiangpin_id) {
ElMessage.warning('请填写用户与奖品')
return
}
saving.value = true
try {
const res = await request.post('/shangpin/adzhuanpankj', {
action: 'save',
huodong_id: currentHd.value.id,
id: kjForm.id,
yonghuid: kjForm.yonghuid,
jiangpin_id: kjForm.jiangpin_id,
cishu: kjForm.cishu,
qiyong: kjForm.qiyong,
beizhu: kjForm.beizhu,
})
if (res.code === 0) {
ElMessage.success('已保存')
kjEditVisible.value = false
loadKongjiang()
} else ElMessage.error(res.msg || '保存失败')
} finally {
saving.value = false
}
}
async function delKongjiang(row) {
await ElMessageBox.confirm('确认删除该控奖?', '提示')
const res = await request.post('/shangpin/adzhuanpankj', {
action: 'delete',
huodong_id: currentHd.value.id,
id: row.id,
})
if (res.code === 0) {
ElMessage.success('已删除')
loadKongjiang()
} else ElMessage.error(res.msg || '删除失败')
}
onMounted(reloadAll)
</script>
<style scoped>
.zhuanpan-page { padding: 8px; }
.tip { margin-bottom: 12px; }
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; }
.form-tip { color: #909399; font-size: 12px; line-height: 1.4; margin-top: 4px; }
</style>

View File

@@ -7,20 +7,28 @@
<div class="no-permission-desc">请联系管理员开通权限</div>
</div>
<div v-else-if="!isAllClubScope" class="scope-block">
<div v-else>
<el-alert
type="warning"
v-if="!isAllClubScope"
type="success"
:closable="false"
show-icon
class="scope-tip"
title="当前为具体小程序视图"
description="此处不可新增/编辑/删除全局商品类型。请切换顶栏为「集团汇总」后使用本页;或在「小程序配置 → 抢单商品类型」中仅做上架/下架与换图标。"
description="可上架/下架集团商品类型(与抢单端一致,名称不可改),并可管理本俱乐部专区。新增/编辑/删除类型定义请切到「集团汇总」。"
/>
<el-alert
v-else
type="info"
:closable="false"
show-icon
class="scope-tip"
title="当前为集团汇总视图"
description="可维护全局商品类型定义。专区按请求头俱乐部写入;管理某小程序专区与上架类型请切到该小程序。"
/>
<el-button type="primary" link @click="$router.push('/miniapp/leixing')">前往抢单商品类型</el-button>
</div>
<div v-else>
<!-- 费率展示区域 -->
<div class="rate-card">
<!-- 费率展示区域集团汇总才改费率 -->
<div class="rate-card" v-if="isAllClubScope">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<div class="rate-item">
@@ -46,7 +54,8 @@
<div class="section-header">
<h3>商品类型</h3>
<div class="header-actions">
<el-button type="primary" size="small" @click="addType">+ 添加类型</el-button>
<el-button v-if="isAllClubScope" type="primary" size="small" @click="addType">+ 添加类型</el-button>
<el-button v-else type="primary" size="small" @click="openShelfCatalog">+ 上架类型</el-button>
<div class="audit-toggle">
<span>显示审核专用</span>
<el-switch v-model="showAuditType" @change="refreshTypeList" />
@@ -71,12 +80,17 @@
<span class="zone-count">专区数{{ getZoneCountByType(type.id) }}</span>
</div>
</div>
<div class="type-actions">
<div class="type-actions" v-if="isAllClubScope">
<el-button type="primary" link size="small" @click.stop="editType(type)">编辑</el-button>
<el-button type="danger" link size="small" @click.stop="deleteType(type)">删除</el-button>
</div>
<div class="type-actions" v-else>
<el-button type="danger" link size="small" @click.stop="disableClubType(type)">下架</el-button>
</div>
</div>
<div v-if="filteredTypeList.length === 0" class="empty-tip">
{{ isAllClubScope ? '暂无商品类型' : '本俱乐部尚未上架类型点击上架类型' }}
</div>
<div v-if="filteredTypeList.length === 0" class="empty-tip">暂无商品类型</div>
</div>
</div>
@@ -199,6 +213,9 @@
<el-form-item label="专区名称" prop="mingzi">
<el-input v-model="zoneForm.mingzi" maxlength="45" />
</el-form-item>
<el-form-item label="专区图标">
<el-input v-model="zoneForm.tupian_url" placeholder="相对路径或完整URL点单端横滑图标" />
</el-form-item>
<el-form-item label="所属类型" prop="leixing_id">
<el-select v-model="zoneForm.leixing_id" placeholder="请选择类型">
<el-option
@@ -235,6 +252,17 @@
<el-button type="primary" @click="submitRate" :loading="rateSubmitting">保存</el-button>
</template>
</el-dialog>
<!-- 俱乐部上架类型目录 -->
<el-dialog v-model="shelfCatalogVisible" title="上架商品类型" width="520px">
<div v-loading="shelfCatalogLoading">
<el-empty v-if="!shelfCatalogLoading && shelfCatalog.length === 0" description="暂无可上架类型(请集团先创建,或已全部上架)" />
<div v-for="c in shelfCatalog" :key="c.id" class="shelf-catalog-row">
<span>{{ c.jieshao }} (ID {{ c.id }})</span>
<el-button size="small" type="primary" @click="enableClubType(c)">上架</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -243,7 +271,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import upload from '@/utils/upload'
import { getAdminClubScope } from '@/utils/club-context'
import { getAdminClubScope, JITUAN_API } from '@/utils/club-context'
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
@@ -260,6 +288,10 @@ const showAuditZone = ref(false)
const ratePlatform = ref(0)
const rateMerchant = ref(0)
const shelfCatalogVisible = ref(false)
const shelfCatalogLoading = ref(false)
const shelfCatalog = ref([])
const formatRate = (val) => (val * 100).toFixed(2) + '%'
const filteredTypeList = computed(() => {
@@ -315,6 +347,65 @@ const refreshTypeList = () => {
const refreshZoneList = () => {}
const selectType = (id) => { selectedTypeId.value = id }
/** 俱乐部:打开可上架类型目录 */
const openShelfCatalog = async () => {
shelfCatalogVisible.value = true
shelfCatalogLoading.value = true
try {
const res = await request.post(JITUAN_API.clubLeixingCatalog, { phone: username.value })
if (res.code === 0) {
if (res.data?.scope_error) {
ElMessage.warning(res.data.scope_error)
shelfCatalog.value = []
return
}
shelfCatalog.value = res.data?.catalog || []
} else {
ElMessage.error(res.msg || '加载目录失败')
}
} catch (e) {
ElMessage.error('网络错误')
} finally {
shelfCatalogLoading.value = false
}
}
const enableClubType = async (c) => {
const res = await request.post(JITUAN_API.clubLeixingEnable, {
phone: username.value,
leixing_id: c.id,
})
if (res.code === 0) {
ElMessage.success('上架成功')
shelfCatalogVisible.value = false
await fetchBaseData()
} else {
ElMessage.error(res.msg || '上架失败')
}
}
const disableClubType = async (type) => {
try {
await ElMessageBox.confirm(
`下架「${type.jieshao}」?点单/抢单将不再展示该类型(本俱乐部专区数据保留)。`,
'确认下架',
{ type: 'warning' }
)
const res = await request.post(JITUAN_API.clubLeixingDisable, {
phone: username.value,
leixing_id: type.id,
})
if (res.code === 0) {
ElMessage.success('已下架')
await fetchBaseData()
} else {
ElMessage.error(res.msg || '下架失败')
}
} catch (e) {
/* cancel */
}
}
// 类型管理
const typeDialogVisible = ref(false)
const typeDialogTitle = ref('')
@@ -497,7 +588,7 @@ const zoneDialogVisible = ref(false)
const zoneDialogTitle = ref('')
const isZoneEdit = ref(false)
const originalZone = ref({})
const zoneForm = reactive({ id: null, mingzi: '', leixing_id: null, shenhezhuangtai: 1 })
const zoneForm = reactive({ id: null, mingzi: '', leixing_id: null, shenhezhuangtai: 1, tupian_url: '' })
const zoneRules = {
mingzi: [{ required: true, message: '请输入专区名称', trigger: 'blur' }],
leixing_id: [{ required: true, message: '请选择所属类型', trigger: 'change' }]
@@ -524,12 +615,13 @@ const editZone = (zone) => {
id: zone.id,
mingzi: zone.mingzi,
leixing_id: zone.leixing_id,
shenhezhuangtai: Number(zone.shenhezhuangtai)
shenhezhuangtai: Number(zone.shenhezhuangtai),
tupian_url: zone.tupian_url || ''
})
zoneDialogVisible.value = true
}
const resetZoneForm = () => {
Object.assign(zoneForm, { id: null, mingzi: '', leixing_id: null, shenhezhuangtai: 1 })
Object.assign(zoneForm, { id: null, mingzi: '', leixing_id: null, shenhezhuangtai: 1, tupian_url: '' })
zoneFormRef.value?.clearValidate()
}
const submitZone = async () => {
@@ -558,7 +650,8 @@ const submitZone = async () => {
id: zoneForm.id,
mingzi: zoneForm.mingzi,
leixing_id: zoneForm.leixing_id,
shenhezhuangtai: zoneForm.shenhezhuangtai
shenhezhuangtai: zoneForm.shenhezhuangtai,
tupian_url: zoneForm.tupian_url || ''
})
if (res.code === 0) {
ElMessage.success(isZoneEdit.value ? '修改成功' : '添加成功')
@@ -676,6 +769,16 @@ onMounted(() => {
background: transparent;
color: #eef2ff;
}
.scope-tip {
margin-bottom: 16px;
}
.shelf-catalog-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.no-permission {
text-align: center;
padding: 80px 20px;

View File

@@ -7,7 +7,7 @@
<el-descriptions-item label="身份">{{ fadan.shenfen_text }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ fadan.fakuanjine }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(fadan.zhuangtai)">{{ statusText(fadan.zhuangtai) }}</el-tag>
<el-tag :type="statusType(fadan)">{{ statusText(fadan) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="影响抢单">{{ fadan.yingxiang_qiangdan ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="罚款理由" :span="2">{{ fadan.chufaliyou }}</el-descriptions-item>
@@ -36,8 +36,14 @@
</div>
</div>
<!-- 处理操作仅申诉中 -->
<div v-if="fadan.zhuangtai === 3" class="actions">
<!-- 平台审核商家提交的罚款状态5 -->
<div v-if="isPlatformAuditPending(fadan)" class="actions">
<el-button type="success" @click="handlePlatformAction('platform_approve')">同意审核进入待缴纳/申诉流程</el-button>
<el-button type="danger" @click="handlePlatformAction('platform_reject')">驳回申请</el-button>
</div>
<!-- 申诉处理状态3 -->
<div v-if="Number(fadan.zhuangtai) === 3" class="actions">
<el-button type="success" @click="handleAction('agree')">同意申诉驳回处罚</el-button>
<el-button type="danger" @click="handleAction('reject')">拒绝申诉</el-button>
</div>
@@ -48,7 +54,7 @@
<el-form-item label="处理理由">
<el-input v-model="reason" type="textarea" :rows="3" placeholder="请输入理由" />
</el-form-item>
<el-form-item label="上传凭证(可选)">
<el-form-item v-if="!isPlatformAction" label="上传凭证(可选)">
<el-upload
:file-list="fileList"
:auto-upload="false"
@@ -73,10 +79,12 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { uploadFiles } from '@/utils/upload'
import { JITUAN_API } from '@/utils/club-context'
import { rowPenaltyStatusText, rowPenaltyStatusType, isPlatformAuditPending } from '@/utils/penalty-status'
const props = defineProps({
visible: Boolean,
@@ -90,16 +98,20 @@ const ossUrl = window.$ossURL || ''
const fullZhengjuUrls = computed(() => (props.fadan?.zhengju_tupian || []).map(url => ossUrl + url))
const fullShensuUrls = computed(() => (props.fadan?.shensu_tupian || []).map(url => ossUrl + url))
// 处理表单
const actionDialog = ref(false)
const actionTitle = ref('')
const currentAction = ref('')
const isPlatformAction = ref(false)
const reason = ref('')
const fileList = ref([])
const selectedFiles = ref([])
const submitting = ref(false)
const statusText = rowPenaltyStatusText
const statusType = rowPenaltyStatusType
const handleAction = (action) => {
isPlatformAction.value = false
currentAction.value = action
actionTitle.value = action === 'agree' ? '同意申诉(处罚将撤销)' : '拒绝申诉'
reason.value = ''
@@ -108,6 +120,16 @@ const handleAction = (action) => {
actionDialog.value = true
}
const handlePlatformAction = (action) => {
isPlatformAction.value = true
currentAction.value = action
actionTitle.value = action === 'platform_approve' ? '同意平台审核' : '驳回罚款申请'
reason.value = ''
fileList.value = []
selectedFiles.value = []
actionDialog.value = true
}
const handleFileChange = (file) => { selectedFiles.value.push(file.raw) }
const handleFileRemove = (file) => {
const idx = selectedFiles.value.indexOf(file.raw)
@@ -117,21 +139,20 @@ const handleFileRemove = (file) => {
const submitAction = async () => {
submitting.value = true
try {
const formData = new FormData()
formData.append('username', localStorage.getItem('username') || '')
formData.append('fadan_id', props.fadan.id)
formData.append('action', currentAction.value)
formData.append('chuli_liyou', reason.value)
selectedFiles.value.forEach(f => formData.append('tupian', f))
// 直接使用 request 发送亦可,这里使用 uploadFiles 并附带字段
// 因为 uploadFiles 接受额外数据和文件,但需要注意文件字段名是 'tupian'
const res = await uploadFiles(JITUAN_API.penaltyAction, selectedFiles.value, {
username: localStorage.getItem('username') || '',
const username = localStorage.getItem('username') || ''
const payload = {
username,
fadan_id: props.fadan.id,
action: currentAction.value,
chuli_liyou: reason.value
}, 'tupian')
}
let res
if (isPlatformAction.value) {
res = await request.post(JITUAN_API.penaltyPlatformAudit, payload)
} else {
res = await uploadFiles(JITUAN_API.penaltyAction, selectedFiles.value, payload, 'tupian')
}
if (res.code === 0) {
ElMessage.success('处理成功')
@@ -148,15 +169,6 @@ const submitAction = async () => {
}
}
const statusText = (code) => ({1:'待缴纳',2:'已缴纳',3:'申诉中',4:'申诉成功'}[code]||'未知')
const statusType = (code) => {
if (code === 1) return 'danger'
if (code === 2) return 'success'
if (code === 3) return 'warning'
if (code === 4) return 'info'
return ''
}
watch(() => props.visible, (val) => { if (!val) actionDialog.value = false })
</script>
@@ -166,5 +178,5 @@ watch(() => props.visible, (val) => { if (!val) actionDialog.value = false })
.section-title { font-weight: 600; margin-bottom: 10px; }
.img-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.grid-img { width: 100px; height: 100px; border-radius: 8px; }
.actions { margin-top: 20px; display: flex; gap: 12px; }
</style>
.actions { margin-top: 20px; display: flex; flex-wrap: wrap; gap: 12px; }
</style>

View File

@@ -8,7 +8,7 @@
<el-table-column prop="chufaliyou" label="罚款理由" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.zhuangtai)">{{ statusText(row.zhuangtai) }}</el-tag>
<el-tag :type="rowPenaltyStatusType(row)">{{ rowPenaltyStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="160" />
@@ -35,6 +35,7 @@
<script setup>
import { computed } from 'vue'
import { rowPenaltyStatusText, rowPenaltyStatusType } from '@/utils/penalty-status'
const props = defineProps({
list: Array,
@@ -48,14 +49,8 @@ defineEmits(['page-change', 'size-change', 'detail'])
const currentPage = computed({ get: () => props.page, set: () => {} })
const currentSize = computed({ get: () => props.pageSize, set: () => {} })
const statusText = (code) => ({1:'待缴纳',2:'已缴纳',3:'申诉中',4:'申诉成功'}[code]||'未知')
const statusType = (code) => {
if (code === 1) return 'danger'
if (code === 2) return 'success'
if (code === 3) return 'warning'
if (code === 4) return 'info'
return ''
}
const statusText = rowPenaltyStatusText
const statusType = rowPenaltyStatusType
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<div class="fadan-management">
<!-- 统计卡片 -->
<FadanStats :stats="stats" @filter="handleStatsFilter" />
<FadanStats :stats="stats" :active-status="currentStatus" @filter="handleStatsFilter" />
<!-- 操作栏 -->
<el-card class="control-card" shadow="hover">
@@ -45,7 +45,7 @@
:visible="detailVisible"
:fadan="selectedFadan"
@close="detailVisible = false"
@processed="fetchData"
@processed="onProcessed"
/>
<!-- 添加弹窗 -->
@@ -72,7 +72,7 @@ import AddFadan from './AddFadan.vue'
const username = localStorage.getItem('username') || ''
const stats = reactive({
total: 0, daijiaona: 0, shensuzhong: 0, yijiaona: 0, yibohui: 0
total: 0, daijiaona: 0, shensuzhong: 0, yijiaona: 0, yibohui: 0, pingtai_shenhe: 0
})
const list = ref([])
@@ -133,7 +133,9 @@ const search = () => { page.value = 1; fetchData(1) }
const resetSearch = () => {
searchKey.value = ''
filterShenfen.value = null
search()
currentStatus.value = null
page.value = 1
fetchData(1)
}
const handlePageChange = (p) => { page.value = p; fetchData(p) }
@@ -144,6 +146,11 @@ const openDetail = (row) => {
detailVisible.value = true
}
const onProcessed = async () => {
await fetchStats()
fetchData()
}
onMounted(async () => {
await fetchStats()
fetchData()

View File

@@ -1,42 +1,15 @@
<template>
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" :class="{ 'no-permission': !permission }" shadow="hover" @click="emitFilter(null)">
<el-col v-for="item in statItems" :key="item.code === null ? 'all' : item.code" :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card
class="stat-card"
:class="{ active: isActive(item.code), 'no-permission': item.code === null && !permission }"
shadow="hover"
@click="emitFilter(item.code)"
>
<div class="stat-item">
<div class="stat-label">总记录</div>
<div class="stat-value">{{ stats.total || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(1)">
<div class="stat-item">
<div class="stat-label">待缴纳</div>
<div class="stat-value danger">{{ stats.daijiaona || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(3)">
<div class="stat-item">
<div class="stat-label">申诉中</div>
<div class="stat-value warning">{{ stats.shensuzhong || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(2)">
<div class="stat-item">
<div class="stat-label">已缴纳</div>
<div class="stat-value success">{{ stats.yijiaona || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(4)">
<div class="stat-item">
<div class="stat-label">申诉成功</div>
<div class="stat-value info">{{ stats.yibohui || 0 }}</div>
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value" :class="item.cls">{{ item.value }}</div>
</div>
</el-card>
</el-col>
@@ -44,20 +17,35 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
stats: Object,
permission: Boolean
permission: Boolean,
activeStatus: { type: [Number, null], default: null },
})
const emit = defineEmits(['filter'])
const emitFilter = (code) => {
if (code === undefined) code = null
emit('filter', code)
}
const statItems = computed(() => [
{ code: null, label: '总记录', value: props.stats?.total || 0, cls: '' },
{ code: 1, label: '待缴纳', value: props.stats?.daijiaona || 0, cls: 'danger' },
{ code: 3, label: '申诉中', value: props.stats?.shensuzhong || 0, cls: 'warning' },
{ code: 2, label: '已缴纳', value: props.stats?.yijiaona || 0, cls: 'success' },
{ code: 5, label: '平台审核中', value: props.stats?.pingtai_shenhe || 0, cls: 'warning' },
{ code: 4, label: '申诉成功', value: props.stats?.yibohui || 0, cls: 'info' },
])
const isActive = (code) => props.activeStatus === code
</script>
<style scoped>
.stats-row { margin-bottom: 20px; }
.stat-card { border-radius: 12px; cursor: pointer; transition: transform 0.2s; }
.stat-card.active { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
.stat-card:hover { transform: translateY(-2px); }
.stat-item { text-align: center; padding: 5px 0; }
.stat-label { font-size: 14px; color: #6b7a88; }

View File

@@ -152,6 +152,7 @@
<div class="detail-actions">
<el-button v-if="!isEditing" type="warning" @click="enterEdit">编辑</el-button>
<el-button v-if="!isEditing" type="success" plain @click="openCopyCatalogDialog">复制店铺商品</el-button>
<template v-else>
<el-button type="primary" @click="saveDetail">保存</el-button>
<el-button @click="cancelEdit">取消</el-button>
@@ -227,6 +228,35 @@
</template>
</el-dialog>
<!-- 复制店铺商品弹窗 -->
<el-dialog v-model="copyCatalogVisible" title="复制店铺商品" width="560px" destroy-on-close>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
title="将源店铺的商品类型、专区、商品完整复制到目标店铺;图片会在存储中独立复制,互不影响。"
/>
<el-form :model="copyCatalogForm" label-width="140px">
<el-form-item label="源店铺用户ID" required>
<el-input v-model="copyCatalogForm.source_user_uid" placeholder="被复制店铺的小程序用户ID" maxlength="7" />
</el-form-item>
<el-form-item label="目标店铺用户ID" required>
<el-input v-model="copyCatalogForm.target_user_uid" placeholder="接收复制的小程序用户ID" maxlength="7" />
</el-form-item>
<el-form-item label="复制模式" required>
<el-radio-group v-model="copyCatalogForm.copy_mode">
<el-radio value="append">补充保留目标店原有追加复制</el-radio>
<el-radio value="replace">全部覆盖清空目标店后复制</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="copyCatalogVisible = false">取消</el-button>
<el-button type="primary" :loading="copyCatalogLoading" @click="submitCopyCatalog">开始复制</el-button>
</template>
</el-dialog>
<!-- 运营数据弹窗内嵌子组件 -->
<el-dialog v-model="operationVisible" title="店铺运营数据" width="80%" top="5vh" destroy-on-close>
<ShopOperationData v-if="operationDianpuId" :dianpu-id="operationDianpuId" />
@@ -236,7 +266,7 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Shop } from '@element-plus/icons-vue'
import request from '@/utils/request'
import ShopOperationData from './components/ShopOperationData.vue' // 引入子组件
@@ -500,6 +530,68 @@ const saveDefaultConfig = async () => {
}
}
// ==================== 复制店铺商品 ====================
const copyCatalogVisible = ref(false)
const copyCatalogLoading = ref(false)
const copyCatalogForm = reactive({
source_user_uid: '',
target_user_uid: '',
copy_mode: 'append',
})
const openCopyCatalogDialog = () => {
const form = detailForm.value
copyCatalogForm.source_user_uid = form?.pingzheng__yonghu ? String(form.pingzheng__yonghu) : ''
copyCatalogForm.target_user_uid = ''
copyCatalogForm.copy_mode = 'append'
copyCatalogVisible.value = true
}
const submitCopyCatalog = async () => {
const sourceUid = copyCatalogForm.source_user_uid.trim()
const targetUid = copyCatalogForm.target_user_uid.trim()
if (!sourceUid || !targetUid) {
ElMessage.warning('请填写源店铺与目标店铺的用户ID')
return
}
if (sourceUid === targetUid) {
ElMessage.warning('源店铺与目标店铺不能相同')
return
}
if (copyCatalogForm.copy_mode === 'replace') {
try {
await ElMessageBox.confirm(
'全部覆盖将清空目标店铺现有的商品类型、专区和商品(含图片),此操作不可撤销,是否继续?',
'危险操作确认',
{ type: 'warning', confirmButtonText: '确认覆盖', cancelButtonText: '取消' }
)
} catch {
return
}
}
copyCatalogLoading.value = true
try {
const res = await request.post('/houtai/htfzdpspsj', {
username: localStorage.getItem('username') || '',
source_user_uid: sourceUid,
target_user_uid: targetUid,
copy_mode: copyCatalogForm.copy_mode,
})
if (res.code === 0 || res.code === 200) {
ElMessage.success(res.msg || '复制成功')
copyCatalogVisible.value = false
} else {
ElMessage.error(res.msg || '复制失败')
}
} catch (error) {
ElMessage.error('请求异常')
} finally {
copyCatalogLoading.value = false
}
}
// ==================== 运营数据弹窗 ====================
const operationVisible = ref(false)
const operationDianpuId = ref(null)

View File

@@ -12,6 +12,16 @@
/>
<span class="switch-desc">开启后商家发布的商品需审核才能上架</span>
</div>
<div class="switch-bar" style="margin-top:12px">
<span class="switch-label">只看审核商品</span>
<el-switch
v-model="zhiKanShenhe"
active-text="开启"
inactive-text="关闭"
:before-change="beforeSwitchZhiKan"
/>
<span class="switch-desc">按当前顶栏俱乐部生效开启后未绑店且未注册打手/商家等身份只看审核专用商品关闭后未绑店一律看正常商品绑店仍看店铺商品集团汇总下不可改请先切到具体小程序</span>
</div>
</el-card>
<!-- 第一区域公共商品类型横排 -->
@@ -213,6 +223,7 @@ import ShopProductList from './components/ShopProductList.vue'
// ========== 审核模式 ==========
const auditMode = ref(false)
const zhiKanShenhe = ref(true)
// 开关前置确认
const beforeSwitchAudit = () => {
@@ -223,15 +234,11 @@ const beforeSwitchAudit = () => {
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
.then(() => {
// 用户确认,先保持开关状态(由 v-model 控制),然后发送请求
// 这里不能直接用 resolve(true),因为需要等待异步请求完成才能决定是否允许切换
// 使用 before-change 的标准流程:先发请求,成功则 resolve(true),失败 reject
handleSwitchAudit()
.then(() => resolve(true))
.catch(() => resolve(false)) // 失败时阻止切换
.catch(() => resolve(false))
})
.catch(() => {
// 取消操作,阻止切换
resolve(false)
})
})
@@ -247,7 +254,42 @@ const handleSwitchAudit = async () => {
})
if (res.code === 0) {
ElMessage.success('审核模式已更新')
// 请求成功后 v-model 会自动切换到 targetValue因为 before-change 返回 true 时执行切换
} else {
ElMessage.error(res.msg || '更新失败')
throw new Error('更新失败')
}
} catch (e) {
ElMessage.error('请求失败,未修改')
throw e
}
}
const beforeSwitchZhiKan = () => {
return new Promise((resolve) => {
ElMessageBox.confirm(
`确定要${zhiKanShenhe.value ? '关闭' : '开启'}「只看审核商品」吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
.then(() => {
handleSwitchZhiKan()
.then(() => resolve(true))
.catch(() => resolve(false))
})
.catch(() => resolve(false))
})
}
const handleSwitchZhiKan = async () => {
const targetValue = !zhiKanShenhe.value
try {
const res = await request.post('/houtai/htxgdpsplx', {
action: 'update_zhi_kan_shenhe',
username: localStorage.getItem('username') || '',
zhi_kan_shenhe: targetValue
})
if (res.code === 0) {
ElMessage.success('只看审核开关已更新')
} else {
ElMessage.error(res.msg || '更新失败')
throw new Error('更新失败')
@@ -282,6 +324,7 @@ const loadPublicTypes = async () => {
// 数据结构:{ code:0, data:{ public_types:[], kaiqi_shenhe: bool } }
publicTypes.value = res.data.public_types || []
auditMode.value = res.data.kaiqi_shenhe || false
zhiKanShenhe.value = res.data.zhi_kan_shenhe !== false
} else if (res.code === 403) {
publicTypes.value = []
ElMessage.error('无访问权限,页面已锁定')

View File

@@ -325,6 +325,46 @@
</div>
</el-card>
<!-- 赠送会员免费接单独立表 -->
<el-card class="info-card" shadow="hover">
<template #header>
<div class="card-header">
<span>🎁 赠送会员免费接单</span>
<el-button
type="success"
size="small"
@click="openGiftDialog()"
:disabled="!hasGiftPermission"
>送会员</el-button>
</div>
<div v-if="!hasGiftPermission" class="member-hint warn">需要打手基础管理或加积分权限</div>
<div v-else class="member-hint">与上方购买会员分开仅用于抢单资格不写购买表</div>
</template>
<div class="member-list">
<div v-for="gift in giftMemberList" :key="'g-' + gift.id" class="member-item gift-item">
<el-tag :type="gift.is_active ? 'success' : 'info'" effect="plain">
{{ gift.huiyuan_name }} 到期: {{ gift.daoqi_time || '--' }}
</el-tag>
<span class="gift-meta">赠送人 {{ gift.song_ren || '--' }} · {{ gift.song_time || '--' }}</span>
<el-button
type="warning"
size="small"
link
@click="openGiftDialog(gift)"
:disabled="!hasGiftPermission"
>改期</el-button>
<el-button
type="danger"
size="small"
link
@click="removeGiftMember(gift)"
:disabled="!hasGiftPermission"
>移除</el-button>
</div>
<div v-if="giftMemberList.length === 0" class="empty-message">暂无赠送会员</div>
</div>
</el-card>
<!-- 其他信息卡片 -->
<el-card class="info-card" shadow="hover">
<template #header>
@@ -377,24 +417,64 @@
<!-- 添加会员弹窗 -->
<el-dialog v-model="memberDialogVisible" title="添加会员" width="400px">
<el-form :model="memberForm" label-width="80px">
<el-form label-width="90px">
<el-form-item label="会员类型" required>
<el-select v-model="memberForm.huiyuan_id" placeholder="请选择会员" style="width: 100%">
<el-select v-model="memberForm.huiyuan_id" placeholder="请选择" filterable style="width: 100%">
<el-option
v-for="item in allMembers"
:key="item.huiyuan_id"
:label="item.jieshao"
:value="item.huiyuan_id"
v-for="m in allMembers"
:key="m.huiyuan_id"
:label="`${m.jieshao} (${m.huiyuan_id})`"
:value="m.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item label="天数" required>
<el-input-number v-model="memberForm.days" :min="1" :step="1" style="width: 100%" />
<el-input-number v-model="memberForm.days" :min="1" :max="3650" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="memberDialogVisible = false">取消</el-button>
<el-button type="primary" @click="addMember" :loading="memberAdding">确认</el-button>
<el-button type="primary" :loading="memberAdding" @click="addMember">确认</el-button>
</template>
</el-dialog>
<!-- 赠送会员弹窗 -->
<el-dialog
v-model="giftDialogVisible"
:title="giftForm.id ? '修改赠送会员到期时间' : '送会员(免费接单)'"
width="420px"
>
<el-form label-width="100px">
<el-form-item label="会员类型" required>
<el-select
v-model="giftForm.huiyuan_id"
placeholder="请选择真实会员类型"
filterable
style="width: 100%"
:disabled="!!giftForm.id"
>
<el-option
v-for="m in allMembers"
:key="'g-' + m.huiyuan_id"
:label="`${m.jieshao} (${m.huiyuan_id})`"
:value="m.huiyuan_id"
/>
</el-select>
</el-form-item>
<el-form-item label="到期日期" required>
<el-date-picker
v-model="giftForm.daoqi_time"
type="datetime"
placeholder="选择到期时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<div class="member-hint">赠送时若积分不是 10将自动改成 10 并写入操作日志</div>
</el-form>
<template #footer>
<el-button @click="giftDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="giftSaving" @click="submitGiftMember">确认</el-button>
</template>
</el-dialog>
</div>
@@ -418,6 +498,7 @@ const username = ref(localStorage.getItem('username') || '')
const playerInfo = ref({})
const originalInfo = ref({})
const memberList = ref([])
const giftMemberList = ref([])
const allMembers = ref([])
const isEditMode = ref(false)
const editData = ref({})
@@ -427,6 +508,11 @@ const memberDialogVisible = ref(false)
const memberForm = reactive({ huiyuan_id: '', days: 30 })
const memberAdding = ref(false)
// 赠送会员弹窗
const giftDialogVisible = ref(false)
const giftSaving = ref(false)
const giftForm = reactive({ id: null, huiyuan_id: '', daoqi_time: '' })
// 权限控制
const userPermissions = ref([])
@@ -436,6 +522,14 @@ const hasMemberPermission = computed(() =>
userPermissions.value.includes('001ff') ||
userPermissions.value.includes('001gg')
)
const hasGiftPermission = computed(() =>
userPermissions.value.includes('000001') ||
userPermissions.value.includes('001aa') ||
userPermissions.value.includes('001bb') ||
userPermissions.value.includes('001cc') ||
userPermissions.value.includes('001dd') ||
userPermissions.value.includes('001ee')
)
const hasChenghaoPermission = computed(() => userPermissions.value.includes('001hh'))
const canChangeInviter = computed(() => userPermissions.value.includes('001ee') || userPermissions.value.includes('000001'))
@@ -536,6 +630,7 @@ const fetchDetail = async () => {
}
originalInfo.value = JSON.parse(JSON.stringify(playerInfo.value))
memberList.value = data.member_list || []
giftMemberList.value = data.gift_member_list || []
allMembers.value = data.all_members || []
userPermissions.value = data.permissions || []
} else if (res.code === 403) {
@@ -714,6 +809,82 @@ const removeMember = async (member) => {
}
// 关键:从路由参数中获取 playerId参数名必须为 playerId
const openGiftDialog = (gift = null) => {
if (!hasGiftPermission.value) {
ElMessage.warning('无赠送会员权限')
return
}
if (gift) {
giftForm.id = gift.id
giftForm.huiyuan_id = gift.huiyuan_id
giftForm.daoqi_time = gift.daoqi_time || ''
} else {
giftForm.id = null
giftForm.huiyuan_id = ''
giftForm.daoqi_time = ''
}
giftDialogVisible.value = true
}
const submitGiftMember = async () => {
if (!giftForm.huiyuan_id) {
ElMessage.warning('请选择会员类型')
return
}
if (!giftForm.daoqi_time) {
ElMessage.warning('请选择到期时间')
return
}
giftSaving.value = true
try {
const payload = {
phone: username.value,
dashou_id: playerId.value,
huiyuan_id: giftForm.huiyuan_id,
daoqi_time: giftForm.daoqi_time,
}
if (giftForm.id) payload.id = giftForm.id
const res = await request.post('/houtai/dszsonghy', payload)
if (res.code === 0) {
ElMessage.success(res.msg || '操作成功')
giftDialogVisible.value = false
await fetchDetail()
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '操作失败')
} finally {
giftSaving.value = false
}
}
const removeGiftMember = async (gift) => {
try {
await ElMessageBox.confirm(
`确定移除赠送会员“${gift.huiyuan_name}”吗?移除后将无法用该赠送资格抢单。`,
'提示',
{ type: 'warning' }
)
} catch { return }
try {
const res = await request.post('/houtai/dsycsonghy', {
phone: username.value,
dashou_id: playerId.value,
id: gift.id,
huiyuan_id: gift.huiyuan_id,
})
if (res.code === 0) {
ElMessage.success('移除成功')
await fetchDetail()
} else {
ElMessage.error(res.msg || '移除失败')
}
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '移除失败')
}
}
onMounted(() => {
playerId.value = route.params.playerId
if (!playerId.value) {
@@ -737,6 +908,15 @@ onMounted(() => {
flex-direction: column;
gap: 12px;
}
.gift-item {
flex-wrap: wrap;
gap: 6px;
}
.gift-meta {
font-size: 12px;
color: #909399;
margin: 0 4px;
}
.member-item {
display: flex;
justify-content: space-between;