Compare commits
48 Commits
backup/kef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52bf301ded | ||
|
|
5aa3b05b44 | ||
|
|
cacae6cfc7 | ||
|
|
db386a0953 | ||
|
|
975e37411e | ||
|
|
ee9c3c2381 | ||
|
|
a57992786c | ||
|
|
33def56c31 | ||
|
|
772d142a40 | ||
|
|
69fe2a51e0 | ||
|
|
f41145bc0d | ||
|
|
87121ffebc | ||
|
|
7c51b1f38b | ||
|
|
23f7263996 | ||
|
|
c5bb036b68 | ||
|
|
e6f6447c30 | ||
|
|
6e7e775c55 | ||
|
|
fa6b456630 | ||
|
|
f1ba100d56 | ||
|
|
5eae3507b0 | ||
|
|
d3dc775bd3 | ||
|
|
75173553a1 | ||
|
|
ab91d9a363 | ||
|
|
0c08243a37 | ||
|
|
1c57cea841 | ||
|
|
bb540ced45 | ||
|
|
ac1fafc817 | ||
|
|
4b20c1fe39 | ||
|
|
41e263dee7 | ||
|
|
f7ec5e63e0 | ||
|
|
b05cb984a5 | ||
|
|
5b89e1b3c8 | ||
|
|
1a5c8aab88 | ||
|
|
9b11749903 | ||
|
|
f3a6fa47bc | ||
|
|
58be204448 | ||
|
|
37a1a15f90 | ||
|
|
21f56619b6 | ||
|
|
4e2e7604c3 | ||
|
|
414c005f93 | ||
|
|
fa81909065 | ||
|
|
288d001579 | ||
|
|
16b04e3905 | ||
|
|
021d00e932 | ||
|
|
6ed29939cb | ||
|
|
1c64f84d68 | ||
|
|
12506c5d32 | ||
|
|
9e7dba32d2 |
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
30
src/utils/penalty-status.js
Normal file
30
src/utils/penalty-status.js
Normal 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
|
||||
}
|
||||
@@ -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('网络错误')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
95
src/views/Welcome.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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="主任职">
|
||||
|
||||
181
src/views/admin/OrderGrabShare.vue
Normal file
181
src/views/admin/OrderGrabShare.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || '网络错误')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
406
src/views/chat/ScriptConfig.vue
Normal file
406
src/views/chat/ScriptConfig.vue
Normal 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>
|
||||
@@ -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
729
src/views/gongdan/List.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<template>
|
||||
<div class="gongdan-page">
|
||||
<div class="stat-row">
|
||||
<div class="stat-card danger" @click="quickFilter(1)">
|
||||
<div class="stat-label">待处理</div>
|
||||
<div class="stat-num">{{ stats.pending }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card warn" @click="quickFilter(3)">
|
||||
<div class="stat-label">不满意</div>
|
||||
<div class="stat-num">{{ stats.dissatisfied }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card escalate" @click="filterEscalated">
|
||||
<div class="stat-label">二次投诉待办</div>
|
||||
<div class="stat-num">{{ stats.escalated }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card need" @click="filterNeedHandle">
|
||||
<div class="stat-label">需处理合计</div>
|
||||
<div class="stat-num">{{ stats.need_handle }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card ok" @click="quickFilter(2)">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-num">{{ stats.done }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
<div class="stat-card muted" @click="resetFilters">
|
||||
<div class="stat-label">全部工单</div>
|
||||
<div class="stat-num">{{ stats.total }}<span class="unit">条</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.zhuangtai" clearable placeholder="全部" style="width:140px">
|
||||
<el-option label="处理中" :value="1" />
|
||||
<el-option label="已处理" :value="2" />
|
||||
<el-option label="不满意" :value="3" />
|
||||
<el-option label="已关闭" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.leixing" clearable placeholder="全部" style="width:140px">
|
||||
<el-option label="订单投诉" :value="1" />
|
||||
<el-option label="充值投诉" :value="2" />
|
||||
<el-option label="罚款投诉" :value="3" />
|
||||
<el-option label="管事投诉" :value="4" />
|
||||
<el-option label="被骗投诉" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="工单号/用户/订单ID/充值ID"
|
||||
style="width:240px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="search">搜索</el-button>
|
||||
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
row-key="gongdan_id"
|
||||
:row-class-name="rowClassName"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<el-table-column label="工单号" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="id-cell">
|
||||
<span class="mono">{{ row.gongdan_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.gongdan_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="badge-row">
|
||||
<el-tag v-if="isPending(row)" type="danger" size="small" effect="dark">待处理</el-tag>
|
||||
<el-tag v-if="row.zhuangtai === 3" type="warning" size="small" effect="dark">不满意</el-tag>
|
||||
<el-tag v-if="(row.shengji_cishu || 0) > 0" type="danger" size="small" effect="plain">
|
||||
第{{ (row.shengji_cishu || 0) + 1 }}次投诉
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="leixing_label" label="类型" width="100" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.zhuangtai)" size="small" effect="dark">
|
||||
{{ row.zhuangtai_label || statusLabel(row.zhuangtai) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投诉次数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': (row.shengji_cishu || 0) > 0 }">
|
||||
{{ (row.shengji_cishu || 0) + 1 }}次
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请人" width="130">
|
||||
<template #default="{ row }">
|
||||
<div class="id-cell">
|
||||
<span>{{ row.shenqingren_id || '-' }}</span>
|
||||
<el-button
|
||||
v-if="row.shenqingren_id"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="copyText(row.shenqingren_id)"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联单号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.order_id" class="id-cell">
|
||||
<span class="muted">订单</span>
|
||||
<span class="mono">{{ row.order_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.order_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.chongzhi_id" class="id-cell">
|
||||
<span class="muted">充值</span>
|
||||
<span class="mono">{{ row.chongzhi_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.chongzhi_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.fadan_id" class="id-cell">
|
||||
<span class="muted">罚单</span>
|
||||
<span class="mono">{{ row.fadan_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.fadan_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="row.guanshi_id" class="id-cell">
|
||||
<span class="muted">管事</span>
|
||||
<span class="mono">{{ row.guanshi_id }}</span>
|
||||
<el-button type="primary" link size="small" @click.stop="copyText(row.guanshi_id)">复制</el-button>
|
||||
</div>
|
||||
<span v-if="!row.order_id && !row.chongzhi_id && !row.fadan_id && !row.guanshi_id">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shuoming" label="说明" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="处理人" width="120">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.chuliren_name || row.chuliren_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click.stop="openDetail(row)">
|
||||
{{ isPending(row) || row.zhuangtai === 3 ? '去处理' : '详情' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawer"
|
||||
title="工单详情"
|
||||
size="520px"
|
||||
append-to-body
|
||||
class="gongdan-drawer"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="current">
|
||||
<div class="detail-wrap">
|
||||
<div class="detail-status-bar" :class="detailStatusClass(current)">
|
||||
<el-tag :type="statusTagType(current.zhuangtai)" effect="dark" size="large">
|
||||
{{ current.zhuangtai_label || statusLabel(current.zhuangtai) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(current.shengji_cishu || 0) > 0" type="danger" effect="plain" size="large">
|
||||
已补充投诉 {{ current.shengji_cishu }} 次 · 共 {{ (current.shengji_cishu || 0) + 1 }} 次
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="detail-row">
|
||||
<span class="label">工单号</span>
|
||||
<span class="value mono">{{ current.gongdan_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.gongdan_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">类型</span>
|
||||
<span class="value">{{ current.leixing_label }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">申请人</span>
|
||||
<span class="value">{{ current.shenqingren_id }}</span>
|
||||
<el-button
|
||||
v-if="current.shenqingren_id"
|
||||
type="primary"
|
||||
link
|
||||
@click="copyText(current.shenqingren_id)"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.order_id" class="detail-row">
|
||||
<span class="label">订单ID</span>
|
||||
<span class="value mono">{{ current.order_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.order_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.chongzhi_id" class="detail-row">
|
||||
<span class="label">充值ID</span>
|
||||
<span class="value mono">{{ current.chongzhi_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.chongzhi_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.fadan_id" class="detail-row">
|
||||
<span class="label">罚单ID</span>
|
||||
<span class="value mono">{{ current.fadan_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.fadan_id)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="current.guanshi_id" class="detail-row">
|
||||
<span class="label">管事ID</span>
|
||||
<span class="value mono">{{ current.guanshi_id }}</span>
|
||||
<el-button type="primary" link @click="copyText(current.guanshi_id)">复制</el-button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ formatTime(current.CreateTime) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">更新时间</span>
|
||||
<span class="value">{{ formatTime(current.UpdateTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">投诉说明</div>
|
||||
<div class="desc-box">{{ current.shuoming || '无' }}</div>
|
||||
<div v-if="current.images?.length" class="imgs">
|
||||
<el-image
|
||||
v-for="(img, i) in current.images"
|
||||
:key="i"
|
||||
:src="img.url"
|
||||
:preview-src-list="current.images.map(x => x.url)"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
style="width:88px;height:88px;margin:4px;border-radius:6px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="current.shengji_list?.length" class="detail-block escalate-block">
|
||||
<div class="block-title">二次 / 补充投诉记录</div>
|
||||
<div v-for="s in current.shengji_list" :key="s.cishu" class="shengji-item">
|
||||
<div class="shengji-head">
|
||||
<el-tag type="danger" size="small">第{{ s.cishu }}次补充</el-tag>
|
||||
<span class="time">{{ formatTime(s.CreateTime) }}</span>
|
||||
</div>
|
||||
<div class="shengji-body">{{ s.shuoming || '无说明' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">处理信息</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">处理人</span>
|
||||
<span class="value">{{ current.chuliren_name || current.chuliren_id || '尚未处理' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">处理时间</span>
|
||||
<span class="value">{{ formatTime(current.chuli_time) || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="current.chuli_jieguo" class="desc-box prev-result">
|
||||
<div class="muted-label">上次处理结果</div>
|
||||
{{ current.chuli_jieguo }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<div class="block-title">填写处理结果</div>
|
||||
<el-input v-model="jieguo" type="textarea" :rows="4" placeholder="填写处理结果(标记已处理时必填)" />
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" @click="handle('done')">标记已处理</el-button>
|
||||
<el-button :loading="saving" @click="handle('close')">关闭工单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const filters = reactive({ zhuangtai: '', leixing: '', keyword: '' })
|
||||
const onlyEscalated = ref(false)
|
||||
const needHandle = ref(false)
|
||||
const drawer = ref(false)
|
||||
const current = ref(null)
|
||||
const jieguo = ref('')
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
dissatisfied: 0,
|
||||
closed: 0,
|
||||
escalated: 0,
|
||||
need_handle: 0,
|
||||
})
|
||||
|
||||
function isPending(row) {
|
||||
return Number(row?.zhuangtai) === 1
|
||||
}
|
||||
|
||||
function statusLabel(z) {
|
||||
return ({ 1: '处理中', 2: '已处理', 3: '不满意', 4: '已关闭' })[Number(z)] || String(z || '')
|
||||
}
|
||||
|
||||
function statusTagType(z) {
|
||||
const n = Number(z)
|
||||
if (n === 1) return 'danger'
|
||||
if (n === 2) return 'success'
|
||||
if (n === 3) return 'warning'
|
||||
if (n === 4) return 'info'
|
||||
return ''
|
||||
}
|
||||
|
||||
function rowClassName({ row }) {
|
||||
if (Number(row.zhuangtai) === 1) return 'row-pending'
|
||||
if (Number(row.zhuangtai) === 3) return 'row-dissatisfied'
|
||||
if ((row.shengji_cishu || 0) > 0 && [1, 3].includes(Number(row.zhuangtai))) return 'row-escalate'
|
||||
return ''
|
||||
}
|
||||
|
||||
function detailStatusClass(row) {
|
||||
const z = Number(row?.zhuangtai)
|
||||
if (z === 1) return 'is-pending'
|
||||
if (z === 3) return 'is-dissatisfied'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTime(v) {
|
||||
if (!v) return ''
|
||||
return String(v).replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
const val = String(text || '').trim()
|
||||
if (!val) {
|
||||
ElMessage.warning('无可复制内容')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
ElMessage.success('已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function applyStats(data) {
|
||||
const s = data?.stats
|
||||
if (s && typeof s === 'object' && (s.total != null || s.pending != null || s.need_handle != null)) {
|
||||
stats.total = Number(s.total) || 0
|
||||
stats.pending = Number(s.pending) || 0
|
||||
stats.done = Number(s.done) || 0
|
||||
stats.dissatisfied = Number(s.dissatisfied) || 0
|
||||
stats.closed = Number(s.closed) || 0
|
||||
stats.escalated = Number(s.escalated) || 0
|
||||
stats.need_handle = Number(s.need_handle) || (stats.pending + stats.dissatisfied)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 旧后端无 stats 字段时:按状态各查一次 total 兜底 */
|
||||
async function loadStatsFallback() {
|
||||
const fetchTotal = async (payload = {}) => {
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/list/', {
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
...payload,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) return Number(res.data?.total) || 0
|
||||
} catch (e) {}
|
||||
return 0
|
||||
}
|
||||
const [pending, done, dissatisfied, closed, total, escalated] = await Promise.all([
|
||||
fetchTotal({ zhuangtai: 1 }),
|
||||
fetchTotal({ zhuangtai: 2 }),
|
||||
fetchTotal({ zhuangtai: 3 }),
|
||||
fetchTotal({ zhuangtai: 4 }),
|
||||
fetchTotal({}),
|
||||
fetchTotal({ only_escalated: 1 }),
|
||||
])
|
||||
stats.pending = pending
|
||||
stats.done = done
|
||||
stats.dissatisfied = dissatisfied
|
||||
stats.closed = closed
|
||||
stats.total = total
|
||||
stats.escalated = escalated
|
||||
stats.need_handle = pending + dissatisfied
|
||||
}
|
||||
|
||||
async function load(p = 1) {
|
||||
page.value = p
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/list/', {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
zhuangtai: needHandle.value || onlyEscalated.value ? '' : filters.zhuangtai,
|
||||
leixing: filters.leixing,
|
||||
keyword: filters.keyword,
|
||||
only_escalated: onlyEscalated.value ? 1 : 0,
|
||||
need_handle: needHandle.value ? 1 : 0,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
const ok = applyStats(res.data)
|
||||
if (!ok) await loadStatsFallback()
|
||||
} else {
|
||||
ElMessage.error(res.message || res.msg || '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
load(page.value)
|
||||
}
|
||||
|
||||
function search() {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
load(1)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.zhuangtai = ''
|
||||
filters.leixing = ''
|
||||
filters.keyword = ''
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
load(1)
|
||||
}
|
||||
|
||||
function quickFilter(zhuangtai) {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = false
|
||||
filters.zhuangtai = zhuangtai
|
||||
load(1)
|
||||
}
|
||||
|
||||
function filterNeedHandle() {
|
||||
onlyEscalated.value = false
|
||||
needHandle.value = true
|
||||
filters.zhuangtai = ''
|
||||
load(1)
|
||||
}
|
||||
|
||||
function filterEscalated() {
|
||||
onlyEscalated.value = true
|
||||
needHandle.value = false
|
||||
filters.zhuangtai = ''
|
||||
load(1)
|
||||
}
|
||||
|
||||
async function openDetail(row) {
|
||||
drawer.value = true
|
||||
jieguo.value = row.chuli_jieguo || ''
|
||||
current.value = row
|
||||
try {
|
||||
const res = await request.post('/gongdan/detail/', { gongdan_id: row.gongdan_id })
|
||||
if (res.code === 200 || res.code === 0) current.value = res.data
|
||||
} catch (e) {
|
||||
// 保留列表行数据
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(action) {
|
||||
if (!current.value) return
|
||||
if (action === 'done' && !jieguo.value.trim()) {
|
||||
ElMessage.warning('请填写处理结果')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post('/gongdan/admin/handle/', {
|
||||
gongdan_id: current.value.gongdan_id,
|
||||
action,
|
||||
chuli_jieguo: jieguo.value,
|
||||
})
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('已保存')
|
||||
drawer.value = false
|
||||
load(page.value)
|
||||
} else ElMessage.error(res.message || res.msg || '失败')
|
||||
} catch (e) {
|
||||
ElMessage.error('处理失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gongdan-page { padding: 12px; color: #1f2937; }
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(6, 12, 20, 0.75);
|
||||
border: 1px solid rgba(0, 242, 255, 0.22);
|
||||
border-radius: 12px;
|
||||
padding: 16px 14px;
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 16px rgba(0, 242, 255, 0.18);
|
||||
border-color: rgba(0, 242, 255, 0.45);
|
||||
}
|
||||
.stat-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(238, 242, 255, 0.72);
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #eef2ff;
|
||||
}
|
||||
.stat-num .unit {
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(238, 242, 255, 0.55);
|
||||
}
|
||||
.stat-card.danger {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
background: linear-gradient(180deg, rgba(127, 29, 29, 0.45) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.danger .stat-num { color: #fca5a5; }
|
||||
.stat-card.warn {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: linear-gradient(180deg, rgba(120, 53, 15, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.warn .stat-num { color: #fcd34d; }
|
||||
.stat-card.escalate {
|
||||
border-color: rgba(251, 113, 133, 0.55);
|
||||
background: linear-gradient(180deg, rgba(136, 19, 55, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.escalate .stat-num { color: #fda4af; }
|
||||
.stat-card.need {
|
||||
border-color: rgba(248, 113, 113, 0.65);
|
||||
background: linear-gradient(180deg, rgba(153, 27, 27, 0.5) 0%, rgba(6, 12, 20, 0.8) 100%);
|
||||
}
|
||||
.stat-card.need .stat-num { color: #fecaca; }
|
||||
.stat-card.ok .stat-num { color: #86efac; }
|
||||
.stat-card.muted .stat-num { color: #93c5fd; }
|
||||
|
||||
.filter-card { margin-bottom: 12px; }
|
||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.actions { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.id-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.muted { color: #9ca3af; font-size: 12px; margin-right: 2px; }
|
||||
.text-danger { color: #dc2626; font-weight: 700; }
|
||||
|
||||
:deep(.row-pending) > td {
|
||||
background: #fef2f2 !important;
|
||||
}
|
||||
:deep(.row-dissatisfied) > td {
|
||||
background: #fffbeb !important;
|
||||
}
|
||||
:deep(.row-escalate) > td {
|
||||
background: #fff1f2 !important;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-status-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.detail-status-bar.is-pending { background: #fef2f2; }
|
||||
.detail-status-bar.is-dissatisfied { background: #fffbeb; }
|
||||
.detail-block {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.escalate-block {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
.block-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: #111827;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.detail-row .label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
.detail-row .value {
|
||||
flex: 1;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
}
|
||||
.desc-box {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #111827;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.prev-result { margin-top: 8px; }
|
||||
.muted-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.shengji-item {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed #fecaca;
|
||||
}
|
||||
.shengji-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.shengji-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.shengji-head .time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.shengji-body {
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stat-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* append-to-body 的抽屉不受 scoped 影响,单独保证可读性 */
|
||||
.gongdan-drawer.el-drawer,
|
||||
.gongdan-drawer .el-drawer__body,
|
||||
.gongdan-drawer .el-drawer__header {
|
||||
color: #1f2937 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
.gongdan-drawer .el-drawer__title {
|
||||
color: #111827 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
271
src/views/miniapp/RankReward.vue
Normal file
271
src/views/miniapp/RankReward.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
522
src/views/product/FakeGrabOrderManage.vue
Normal file
522
src/views/product/FakeGrabOrderManage.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
552
src/views/product/Turntable.vue
Normal file
552
src/views/product/Turntable.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('无访问权限,页面已锁定')
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user