backup: kefu state before examiner-add feature on ExaminerManage
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"axios": "^1.13.5",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.2",
|
||||
"goeasy": "^2.14.9",
|
||||
"goeasy": "2.13.24",
|
||||
"lodash": "^4.18.1",
|
||||
"mitt": "^3.0.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
@@ -1500,9 +1500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/goeasy": {
|
||||
"version": "2.14.9",
|
||||
"resolved": "https://registry.npmjs.org/goeasy/-/goeasy-2.14.9.tgz",
|
||||
"integrity": "sha512-AlHx7PCWfIUWKAvjILJx6AzM1GmquGM1MxxA7gPH6TiR9eTtiieJNk9nIXvVYRm0iTC8AfEnekRY2Clghm9xrA=="
|
||||
"version": "2.13.24",
|
||||
"resolved": "https://registry.npmjs.org/goeasy/-/goeasy-2.13.24.tgz",
|
||||
"integrity": "sha512-DIoAS4C3E6ViQxW6dUDanQOkKVRwZyhFMLZkwuIqgoamIE36E9fHu0RDumNq4ec+aRt9FnbpA7/ibl38OSTTvA=="
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
@@ -2739,9 +2739,9 @@
|
||||
}
|
||||
},
|
||||
"goeasy": {
|
||||
"version": "2.14.9",
|
||||
"resolved": "https://registry.npmjs.org/goeasy/-/goeasy-2.14.9.tgz",
|
||||
"integrity": "sha512-AlHx7PCWfIUWKAvjILJx6AzM1GmquGM1MxxA7gPH6TiR9eTtiieJNk9nIXvVYRm0iTC8AfEnekRY2Clghm9xrA=="
|
||||
"version": "2.13.24",
|
||||
"resolved": "https://registry.npmjs.org/goeasy/-/goeasy-2.13.24.tgz",
|
||||
"integrity": "sha512-DIoAS4C3E6ViQxW6dUDanQOkKVRwZyhFMLZkwuIqgoamIE36E9fHu0RDumNq4ec+aRt9FnbpA7/ibl38OSTTvA=="
|
||||
},
|
||||
"gopd": {
|
||||
"version": "1.2.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"axios": "^1.13.5",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.2",
|
||||
"goeasy": "^2.14.9",
|
||||
"goeasy": "2.13.24",
|
||||
"lodash": "^4.18.1",
|
||||
"mitt": "^3.0.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@open="onDialogOpen"
|
||||
@opened="onDialogOpened"
|
||||
@close="onDialogClose"
|
||||
>
|
||||
<div class="chat-container">
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="input-area">
|
||||
<el-input v-model="inputText" placeholder="请输入消息..." @keyup.enter="sendText" size="small">
|
||||
<template #append>
|
||||
<el-button @click="sendText" :disabled="!inputText.trim()">发送</el-button>
|
||||
<el-button @click="sendText" :disabled="!inputText.trim() || !agentOnline">发送</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
@@ -63,16 +63,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
isCsMessageForCustomer,
|
||||
buildCsTextMessage,
|
||||
ensureCustomerAccepted
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
teamId: String,
|
||||
customerId: String,
|
||||
customerData: Object
|
||||
customerData: Object,
|
||||
agentOnline: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
@@ -97,12 +105,42 @@ const isSelf = (msg) => msg.senderId === agentId.value
|
||||
|
||||
let csteam = null
|
||||
let unlisten = null
|
||||
let sessionReady = false
|
||||
|
||||
const pushMessage = (message) => {
|
||||
if (!message?.messageId) return
|
||||
if (messages.value.some(m => m.messageId === message.messageId)) return
|
||||
message.showTime = shouldShowTime(message.timestamp)
|
||||
messages.value.push(message)
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
}
|
||||
|
||||
const handleIncomingMessage = (message) => {
|
||||
if (!isCsMessageForCustomer(message, props.customerId, props.teamId, agentId.value)) return
|
||||
console.log('[ChatDialog] 收到实时消息:', message)
|
||||
const tempMsg = messages.value.find(
|
||||
m => m._tempId && m.payload?.text === message.payload?.text && m.senderId === agentId.value
|
||||
)
|
||||
if (tempMsg && message.senderId === agentId.value) {
|
||||
tempMsg.messageId = message.messageId
|
||||
tempMsg.status = 'success'
|
||||
tempMsg.timestamp = message.timestamp
|
||||
tempMsg._tempId = null
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
return
|
||||
}
|
||||
pushMessage(message)
|
||||
}
|
||||
|
||||
const onGlobalCsMessage = (message) => {
|
||||
handleIncomingMessage(message)
|
||||
}
|
||||
|
||||
// ----- 使用 listenCustomer 监听客户消息(GoEasy 官方方案) -----
|
||||
const startListenCustomer = () => {
|
||||
if (!window.goeasy || !props.teamId || !props.customerId) return
|
||||
csteam = window.goeasy.im.csteam(props.teamId)
|
||||
// 先清除旧的监听
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
@@ -110,24 +148,7 @@ const startListenCustomer = () => {
|
||||
csteam.listenCustomer({
|
||||
id: props.customerId,
|
||||
onNewMessage: (message) => {
|
||||
console.log('[ChatDialog] 客户新消息:', message)
|
||||
// 避免与临时消息重复
|
||||
const tempMsg = messages.value.find(m => m._tempId && m.payload?.text === message.payload?.text)
|
||||
if (tempMsg && message.status === 'success') {
|
||||
tempMsg.messageId = message.messageId
|
||||
tempMsg.status = 'success'
|
||||
tempMsg.timestamp = message.timestamp
|
||||
tempMsg._tempId = null
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
return
|
||||
}
|
||||
// 去重
|
||||
if (messages.value.some(m => m.messageId === message.messageId)) return
|
||||
message.showTime = shouldShowTime(message.timestamp)
|
||||
messages.value.push(message)
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
handleIncomingMessage(message)
|
||||
},
|
||||
onStatusUpdated: (status) => {
|
||||
console.log('[ChatDialog] 客户状态更新:', status)
|
||||
@@ -139,13 +160,9 @@ const startListenCustomer = () => {
|
||||
console.error('[ChatDialog] 监听客户失败:', error)
|
||||
}
|
||||
})
|
||||
unlisten = () => {
|
||||
// GoEasy 未提供直接取消 listenCustomer 的方法,通常切换客户时重新调用 listenCustomer 即可
|
||||
// 这里在弹窗关闭时通过清除整个 csteam 实例来处理(或保持连接)
|
||||
}
|
||||
unlisten = () => {}
|
||||
}
|
||||
|
||||
// ----- 加载历史 -----
|
||||
const loadHistory = () => {
|
||||
if (!props.teamId || !props.customerId) return
|
||||
const csteam = window.goeasy?.im?.csteam(props.teamId)
|
||||
@@ -189,24 +206,28 @@ const refreshHistory = () => {
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
// ----- 发送消息 -----
|
||||
const sendText = () => {
|
||||
const sendText = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text) return
|
||||
if (!window.goeasy) return ElMessage.error('聊天服务未连接')
|
||||
|
||||
const message = window.goeasy.im.createTextMessage({
|
||||
text: text,
|
||||
to: {
|
||||
type: 'cs',
|
||||
id: props.customerId,
|
||||
data: props.customerData || { name: props.customerId, avatar: '' }
|
||||
if (!props.agentOnline && !window.__kefuCsOnline) {
|
||||
ElMessage.warning('客服正在连接,请稍候…')
|
||||
return
|
||||
}
|
||||
if (!sessionReady) {
|
||||
try {
|
||||
await ensureCustomerAccepted(props.customerId, props.customerData)
|
||||
sessionReady = true
|
||||
} catch (error) {
|
||||
ElMessage.error('会话未接入:' + (error?.content || '未知错误'))
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
message.teamId = props.teamId
|
||||
const customerData = props.customerData || { name: props.customerId, avatar: '' }
|
||||
const message = buildCsTextMessage(props.teamId, props.customerId, customerData, text)
|
||||
|
||||
console.log('[ChatDialog] 发送消息:', message)
|
||||
console.log('[ChatDialog] 发送消息 → 用户ID:', props.customerId, message)
|
||||
|
||||
const tempId = 'local-' + Date.now()
|
||||
const tempMsg = {
|
||||
@@ -247,7 +268,6 @@ const sendText = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ----- 工具函数 -----
|
||||
const shouldShowTime = (ts) => {
|
||||
if (messages.value.length === 0) return true
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
@@ -271,23 +291,36 @@ const markRead = () => {
|
||||
}
|
||||
const previewImage = (url) => window.open(url)
|
||||
|
||||
// ----- 弹窗生命周期 -----
|
||||
const onDialogOpen = () => {
|
||||
console.log('[ChatDialog] 弹窗打开')
|
||||
const onDialogOpened = async () => {
|
||||
console.log('[ChatDialog] 弹窗已打开, customerId=', props.customerId)
|
||||
if (!agentId.value) agentId.value = window.__kefuAgentId || 'KF' + localStorage.getItem('username')
|
||||
// 先监听客户,再加载历史
|
||||
sessionReady = false
|
||||
proxy.$emitter.on('kefu-cs-new-message', onGlobalCsMessage)
|
||||
|
||||
try {
|
||||
await ensureCustomerAccepted(
|
||||
props.customerId,
|
||||
props.customerData || { name: props.customerId, avatar: '' }
|
||||
)
|
||||
sessionReady = true
|
||||
} catch (error) {
|
||||
ElMessage.warning('接入会话:' + (error?.content || '请稍后重试'))
|
||||
}
|
||||
|
||||
startListenCustomer()
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
const onDialogClose = () => {
|
||||
// 关闭弹窗时不需要主动取消 listenCustomer,因为我们会重新 listen
|
||||
// 但可以清理 unlisten 引用
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
sessionReady = false
|
||||
messages.value = []
|
||||
lastTimestamp.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
proxy.$emitter.off('kefu-cs-new-message', onGlobalCsMessage)
|
||||
unlisten = null
|
||||
})
|
||||
</script>
|
||||
@@ -311,4 +344,4 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
31
src/components/ClubScopeHint.vue
Normal file
31
src/components/ClubScopeHint.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div v-if="hint" class="club-scope-hint">{{ hint }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { getAdminClubContext, getAdminClubScope } from '@/utils/club-context'
|
||||
|
||||
const hint = computed(() => {
|
||||
const ctx = getAdminClubContext()
|
||||
if (getAdminClubScope() === 'all') {
|
||||
return '当前为集团汇总视图,下列表展示全部子公司数据'
|
||||
}
|
||||
const cid = ctx?.club_id || 'xq'
|
||||
const clubs = ctx?.clubs || []
|
||||
const hit = clubs.find((c) => c.club_id === cid)
|
||||
const name = hit?.name || cid
|
||||
return `当前数据范围:${name}(${cid})`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.club-scope-hint {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 6px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,91 +3,108 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { onMounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
afterGoeasyConnected,
|
||||
resumeKefuSession,
|
||||
onGoeasyReconnected,
|
||||
isGoeasyConnected
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits(['connected'])
|
||||
|
||||
// 每次进入页面都重新获取权限并建立连接
|
||||
async function connect() {
|
||||
console.log('[KefuConnect] 开始获取客服权限...')
|
||||
const phone = localStorage.getItem('username')
|
||||
if (!phone) {
|
||||
console.error('[KefuConnect] 未找到手机号')
|
||||
return
|
||||
}
|
||||
if (!phone) return
|
||||
|
||||
try {
|
||||
const res = await request.post('/houtai/kfhqltqx', { phone })
|
||||
if (res.code !== 0) {
|
||||
console.warn('[KefuConnect] 接口返回非0:', res)
|
||||
return
|
||||
}
|
||||
const { permissions, goeasy_appkey } = res.data
|
||||
console.log('[KefuConnect] 权限码:', permissions)
|
||||
console.log('[KefuConnect] GoEasy appkey:', goeasy_appkey)
|
||||
if (res.code !== 0) return
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
console.warn('[KefuConnect] 无聊天权限,不能连接')
|
||||
ElMessage.warning('您没有客服聊天权限')
|
||||
const { permissions, goeasy_appkey } = res.data
|
||||
if (!permissions?.length) {
|
||||
console.warn('[KefuConnect] 无聊天权限')
|
||||
return
|
||||
}
|
||||
if (!goeasy_appkey) {
|
||||
console.warn('[KefuConnect] 未获取到 appkey')
|
||||
console.error('[KefuConnect] 后端未返回 goeasy_appkey')
|
||||
ElMessage.error('消息服务未配置 AppKey,请联系管理员')
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||
return
|
||||
}
|
||||
|
||||
// 断开旧连接
|
||||
if (window.goeasy) {
|
||||
console.log('[KefuConnect] 断开旧连接...')
|
||||
try { window.goeasy.disconnect({}) } catch (e) {}
|
||||
window.goeasy = null
|
||||
const agentId = 'KF' + phone
|
||||
|
||||
if (
|
||||
window.goeasy &&
|
||||
window.__kefuAgentId === agentId &&
|
||||
window.__kefuAppkey === goeasy_appkey &&
|
||||
isGoeasyConnected()
|
||||
) {
|
||||
resumeKefuSession(proxy.$emitter, phone)
|
||||
emit('connected')
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
return
|
||||
}
|
||||
|
||||
const agentId = 'KF' + phone
|
||||
console.log('[KefuConnect] 客服ID:', agentId)
|
||||
if (window.goeasy) {
|
||||
try { window.goeasy.disconnect({}) } catch (e) {}
|
||||
window.goeasy = null
|
||||
window.__kefuCsOnline = false
|
||||
}
|
||||
|
||||
window.__kefuAppkey = goeasy_appkey
|
||||
if (window.__kefuUserWantsOffline === undefined) {
|
||||
window.__kefuUserWantsOffline = false
|
||||
}
|
||||
|
||||
// 创建新实例
|
||||
const goeasy = GoEasy.getInstance({
|
||||
host: 'hangzhou.goeasy.io', // 根据实际区域修改
|
||||
host: 'hangzhou.goeasy.io',
|
||||
appkey: goeasy_appkey,
|
||||
modules: ['im']
|
||||
})
|
||||
console.log('[KefuConnect] GoEasy 实例创建成功,准备连接...')
|
||||
|
||||
goeasy.connect({
|
||||
id: agentId,
|
||||
data: { name: phone, avatar: '' },
|
||||
onSuccess: () => {
|
||||
console.log('[KefuConnect] ✅ 连接成功!')
|
||||
window.goeasy = goeasy
|
||||
window.__kefuAgentId = agentId
|
||||
// 通知父组件(Agent.vue)可以进行上线操作
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', true)
|
||||
afterGoeasyConnected(proxy.$emitter, phone)
|
||||
emit('connected')
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[KefuConnect] ❌ 连接失败:', error)
|
||||
ElMessage.error('消息服务连接失败,请重试')
|
||||
console.error('[KefuConnect] 连接失败:', error)
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||
ElMessage.error('消息服务连接失败,请刷新重试')
|
||||
},
|
||||
onProgress: (attempts) => {
|
||||
console.log('[KefuConnect] 🔄 重连中... 第' + attempts + '次')
|
||||
console.log('[KefuConnect] 重连中…', attempts)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
goeasy.on && goeasy.on('reconnected', () => {
|
||||
console.log('[KefuConnect] 重连成功')
|
||||
onGoeasyReconnected(proxy.$emitter, phone)
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KefuConnect] 连接异常:', e)
|
||||
proxy.$emitter.emit('kefu-goeasy-ready', false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[KefuConnect] 组件挂载,开始建立客服长连接')
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log('[KefuConnect] 组件卸载,但保持连接存活(可后续优化)')
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
61
src/components/UserScopeHint.vue
Normal file
61
src/components/UserScopeHint.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="user-scope-wrap">
|
||||
<div v-if="hint" class="scope-hint">{{ hint }}</div>
|
||||
<el-collapse v-if="byClub.length" class="club-breakdown">
|
||||
<el-collapse-item title="各子公司用户统计(集团汇总)" name="breakdown">
|
||||
<el-table :data="byClub" size="small" stripe>
|
||||
<el-table-column prop="name" label="俱乐部" width="120" />
|
||||
<el-table-column prop="club_id" label="ID" width="80" />
|
||||
<el-table-column prop="dashou_total" label="打手" width="70" />
|
||||
<el-table-column prop="dashou_valid" label="有效打手" width="90" />
|
||||
<el-table-column prop="guanshi_total" label="管事" width="70" />
|
||||
<el-table-column prop="shangjia_total" label="商家" width="70" />
|
||||
<el-table-column prop="zuzhang_total" label="组长" width="70" />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API, getAdminClubContext, getAdminClubScope } from '@/utils/club-context'
|
||||
|
||||
const summary = ref(null)
|
||||
const byClub = computed(() => summary.value?.by_club || [])
|
||||
const hint = computed(() => {
|
||||
const ctx = getAdminClubContext()
|
||||
if (getAdminClubScope() === 'all') {
|
||||
return '当前为集团汇总视图,下列表展示全部子公司用户'
|
||||
}
|
||||
const cid = ctx?.club_id || 'xq'
|
||||
return `当前数据范围:俱乐部 ${cid}`
|
||||
})
|
||||
|
||||
const fetchSummary = async () => {
|
||||
const username = localStorage.getItem('username') || ''
|
||||
if (!username) return
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.userSummary, { phone: username })
|
||||
if (res.code === 0) {
|
||||
summary.value = res.data
|
||||
}
|
||||
} catch {
|
||||
/* 汇总失败不影响列表 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchSummary)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-hint {
|
||||
font-size: 13px;
|
||||
color: #9aa0b5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.club-breakdown {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -72,6 +72,7 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import WaterTankFishbowl from '@/components/WaterTankFishbowl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -119,7 +120,7 @@ const waterRemainingPercent = (role) => {
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/hthqtxpz', { username: username.value })
|
||||
const res = await request.post(JITUAN_API.withdrawGet, { username: username.value })
|
||||
if (res.code === 0) {
|
||||
const data = res.data
|
||||
roles.value.forEach(role => {
|
||||
@@ -192,7 +193,7 @@ const saveSettings = async () => {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/htxgtxsz', {
|
||||
const res = await request.post(JITUAN_API.withdrawUpdate, {
|
||||
username: username.value,
|
||||
rates: editData.rates,
|
||||
quotas: editData.quotas,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Layout from '@/views/Layout.vue'
|
||||
import request from '@/utils/request'
|
||||
import {
|
||||
ensureMenuAccess,
|
||||
getMenuAccess,
|
||||
getFirstVisiblePath,
|
||||
isMenuSessionFresh,
|
||||
JITUAN_API,
|
||||
} from '@/utils/club-context'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -9,7 +17,7 @@ const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/order/platform',
|
||||
redirect: () => getFirstVisiblePath() || '/order/platform',
|
||||
children: [
|
||||
|
||||
|
||||
@@ -126,6 +134,11 @@ const routes = [
|
||||
component: () => import('@/views/user/ZuzhangDetail.vue'),
|
||||
meta: { title: '组长详情' }
|
||||
},
|
||||
{
|
||||
path: 'user/identity-tag',
|
||||
component: () => import('@/views/user/IdentityTag.vue'),
|
||||
meta: { title: '身份装饰标签' }
|
||||
},
|
||||
// 提现管理(原有)
|
||||
{
|
||||
path: 'withdraw/audit',
|
||||
@@ -148,6 +161,16 @@ const routes = [
|
||||
meta: { title: '提现详情' }
|
||||
},
|
||||
// 管理员管理(原有)
|
||||
{
|
||||
path: 'admin/club-config',
|
||||
component: () => import('@/views/admin/ClubConfig.vue'),
|
||||
meta: { title: '俱乐部密钥配置' }
|
||||
},
|
||||
{
|
||||
path: 'admin/data-scope',
|
||||
component: () => import('@/views/admin/DataScope.vue'),
|
||||
meta: { title: '数据范围配置' }
|
||||
},
|
||||
{
|
||||
path: 'admin/role',
|
||||
component: () => import('@/views/admin/Role.vue'),
|
||||
@@ -168,6 +191,11 @@ const routes = [
|
||||
component: () => import('@/views/admin/AdminUserDetail.vue'),
|
||||
meta: { title: '后台用户详情' }
|
||||
},
|
||||
{
|
||||
path: 'admin/operation-log',
|
||||
component: () => import('@/views/admin/OperationLog.vue'),
|
||||
meta: { title: '操作日志' }
|
||||
},
|
||||
// 处罚管理(原有)
|
||||
{
|
||||
path: 'punishment',
|
||||
@@ -218,6 +246,31 @@ const routes = [
|
||||
component: () => import('@/views/miniapp/PopupNotice.vue'),
|
||||
meta: { title: '弹窗公告管理' }
|
||||
},
|
||||
{
|
||||
path: 'miniapp/carousel',
|
||||
component: () => import('@/views/miniapp/CarouselManage.vue'),
|
||||
meta: { title: '轮播图与公告' }
|
||||
},
|
||||
{
|
||||
path: 'miniapp/assets',
|
||||
component: () => import('@/views/miniapp/MiniappAssets.vue'),
|
||||
meta: { title: '图标与频道' }
|
||||
},
|
||||
{
|
||||
path: 'miniapp/leixing',
|
||||
component: () => import('@/views/miniapp/ShangpinLeixingClub.vue'),
|
||||
meta: { title: '抢单商品类型' }
|
||||
},
|
||||
{
|
||||
path: 'miniapp/kaohe-tags',
|
||||
component: () => import('@/views/miniapp/KaoheTagClub.vue'),
|
||||
meta: { title: '抢单考核标签' }
|
||||
},
|
||||
{
|
||||
path: 'miniapp/dashou-exam',
|
||||
component: () => import('@/views/miniapp/DashouExam.vue'),
|
||||
meta: { title: '打手接单考试' }
|
||||
},
|
||||
// ========== 店铺管理(原有) ==========
|
||||
{
|
||||
path: 'shop/list',
|
||||
@@ -250,13 +303,69 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
/** 详情页等子路由:父模块在 visible_paths 中即允许访问 */
|
||||
function isRouteAllowed(path, visiblePaths) {
|
||||
if (!visiblePaths?.length) return true
|
||||
if (visiblePaths.some((p) => p && (path === p || path.startsWith(`${p}/`)))) {
|
||||
return true
|
||||
}
|
||||
const rules = [
|
||||
{ prefix: '/withdraw/detail/', parents: ['/withdraw/audit', '/withdraw/data'] },
|
||||
{ prefix: '/punishment/detail/', parents: ['/punishment'] },
|
||||
{ prefix: '/order/detail/', parents: ['/order/platform', '/order/merchant'] },
|
||||
{ prefix: '/cross-order/detail/', parents: ['/cross-order'] },
|
||||
{ prefix: '/user/player/detail/', parents: ['/user/player'] },
|
||||
{ prefix: '/user/guanli/detail/', parents: ['/user/guanli'] },
|
||||
{ prefix: '/user/shangjia/detail/', parents: ['/user/shangjia'] },
|
||||
{ prefix: '/user/zuzhang/detail/', parents: ['/user/zuzhang'] },
|
||||
{ prefix: '/product/detail/', parents: ['/product/list'] },
|
||||
{ prefix: '/admin/role/detail/', parents: ['/admin/role'] },
|
||||
{ prefix: '/admin/user/detail/', parents: ['/admin/user'] },
|
||||
{ prefix: '/miniapp/assets', parents: ['/miniapp/carousel', '/miniapp/assets'] },
|
||||
]
|
||||
for (const { prefix, parents } of rules) {
|
||||
if (path.startsWith(prefix)) {
|
||||
return visiblePaths.some((p) => parents.includes(p))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.path !== '/login' && !token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureMenuAccess(async () => {
|
||||
const res = await request.get(JITUAN_API.menuAccess)
|
||||
return res.code === 0 ? res.data : null
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('路由加载菜单权限失败:', e)
|
||||
}
|
||||
|
||||
const access = getMenuAccess()
|
||||
if (!isMenuSessionFresh() || !access?.visible_paths?.length) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const path = to.path
|
||||
if (!isRouteAllowed(path, access.visible_paths)) {
|
||||
const fallback = getFirstVisiblePath()
|
||||
if (fallback && path !== fallback) {
|
||||
next(fallback)
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
211
src/utils/club-context.js
Normal file
211
src/utils/club-context.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const CLUB_ID_KEY = 'admin_club_id';
|
||||
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 DEFAULT_CLUB_ID = 'xq';
|
||||
|
||||
let menuAccessInflight = null;
|
||||
|
||||
export function getAdminClubId() {
|
||||
return localStorage.getItem(CLUB_ID_KEY) || DEFAULT_CLUB_ID;
|
||||
}
|
||||
|
||||
export function getAdminClubScope() {
|
||||
return localStorage.getItem(CLUB_SCOPE_KEY) || 'single';
|
||||
}
|
||||
|
||||
export function setAdminClubContext(ctx) {
|
||||
if (!ctx) return;
|
||||
localStorage.setItem(CLUB_CONTEXT_KEY, JSON.stringify(ctx));
|
||||
localStorage.setItem(CLUB_ID_KEY, ctx.club_id || DEFAULT_CLUB_ID);
|
||||
localStorage.setItem(CLUB_SCOPE_KEY, ctx.scope === 'ALL_CLUBS' ? 'all' : 'single');
|
||||
}
|
||||
|
||||
export function mergeServerClubContext(serverCtx) {
|
||||
if (!serverCtx) return;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (prevScope) {
|
||||
localStorage.setItem(CLUB_SCOPE_KEY, prevScope);
|
||||
} else {
|
||||
localStorage.setItem(CLUB_SCOPE_KEY, merged.scope === 'ALL_CLUBS' ? 'all' : 'single');
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function getAdminClubContext() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CLUB_CONTEXT_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setMenuAccess(access) {
|
||||
if (!access) return;
|
||||
localStorage.setItem(MENU_ACCESS_KEY, JSON.stringify(access));
|
||||
sessionStorage.setItem(MENU_SESSION_KEY, MENU_SESSION_VERSION);
|
||||
}
|
||||
|
||||
export function clearMenuAccess() {
|
||||
localStorage.removeItem(MENU_ACCESS_KEY);
|
||||
sessionStorage.removeItem(MENU_SESSION_KEY);
|
||||
}
|
||||
|
||||
export function getMenuAccess() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MENU_ACCESS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMenuSessionFresh() {
|
||||
return sessionStorage.getItem(MENU_SESSION_KEY) === MENU_SESSION_VERSION;
|
||||
}
|
||||
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function getFirstVisiblePath() {
|
||||
const access = getMenuAccess();
|
||||
if (!access || !Array.isArray(access.visible_paths) || access.visible_paths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return access.visible_paths[0];
|
||||
}
|
||||
|
||||
export function canSwitchClubByMenu() {
|
||||
const access = getMenuAccess();
|
||||
if (!access || !isMenuSessionFresh()) {
|
||||
const ctx = getAdminClubContext();
|
||||
return !!ctx?.can_switch_club;
|
||||
}
|
||||
return !!access.can_switch_club;
|
||||
}
|
||||
|
||||
export async function ensureMenuAccess(fetcher) {
|
||||
if (!localStorage.getItem('token')) return getMenuAccess();
|
||||
if (isMenuSessionFresh() && getMenuAccess()) {
|
||||
return getMenuAccess();
|
||||
}
|
||||
if (!menuAccessInflight) {
|
||||
menuAccessInflight = (async () => {
|
||||
try {
|
||||
const data = await fetcher();
|
||||
if (data) setMenuAccess(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('ensureMenuAccess failed:', e);
|
||||
return getMenuAccess();
|
||||
} finally {
|
||||
menuAccessInflight = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return menuAccessInflight;
|
||||
}
|
||||
|
||||
export function applyClubHeaders(config = {}) {
|
||||
const headers = { ...(config.headers || {}) };
|
||||
const clubId = getAdminClubId();
|
||||
const scope = getAdminClubScope();
|
||||
headers['X-Club-Id'] = clubId;
|
||||
if (scope === 'all') {
|
||||
headers['X-Club-Scope'] = 'all';
|
||||
}
|
||||
return { ...config, headers };
|
||||
}
|
||||
|
||||
export const JITUAN_API = {
|
||||
kefuLogin: '/jituan/auth/kefu-login',
|
||||
meContext: '/jituan/auth/me-context',
|
||||
menuAccess: '/jituan/auth/menu-access',
|
||||
clubList: '/jituan/club/list',
|
||||
caiwu: '/jituan/houtai/caiwu',
|
||||
szxx: '/jituan/houtai/szxx',
|
||||
auditLog: '/jituan/houtai/audit-log',
|
||||
orderTypes: '/jituan/houtai/cwddhqlx',
|
||||
orderFinance: '/jituan/houtai/cwhqjtddsj',
|
||||
chongzhiFinance: '/jituan/houtai/cwqtczhq',
|
||||
huiyuanBankuai: '/jituan/houtai/cwhybkhq',
|
||||
huiyuanStats: '/jituan/houtai/hybkjtsj',
|
||||
userSummary: '/jituan/houtai/user-summary',
|
||||
dashouList: '/jituan/houtai/kefuhqdslb',
|
||||
guanshiList: '/jituan/houtai/hthqgslb',
|
||||
shangjiaList: '/jituan/houtai/hqsjgllb',
|
||||
zuzhangList: '/jituan/houtai/hthqzzlb',
|
||||
operationLog: '/jituan/houtai/hqczrz',
|
||||
withdrawGet: '/jituan/houtai/hthqtxpz',
|
||||
withdrawUpdate: '/jituan/houtai/htxgtxsz',
|
||||
lunboList: '/jituan/houtai/lunbo-list',
|
||||
lunboManage: '/jituan/houtai/lunbo-manage',
|
||||
gonggao: '/jituan/houtai/gonggao',
|
||||
tupianList: '/jituan/houtai/tupian-list',
|
||||
tupianManage: '/jituan/houtai/tupian-manage',
|
||||
popupList: '/jituan/houtai/hthqtcxx',
|
||||
popupModify: '/jituan/houtai/htxgtcxx',
|
||||
penaltyStats: '/jituan/houtai/htglyhqcfsltj',
|
||||
penaltyList: '/jituan/houtai/hthqfklb',
|
||||
penaltyAction: '/jituan/houtai/glyclfk',
|
||||
penaltyCreate: '/jituan/houtai/htfksc',
|
||||
kefuCfgl: '/jituan/houtai/kefu-cfgl',
|
||||
adminAssignments: '/jituan/houtai/admin-assignments',
|
||||
clubManage: '/jituan/houtai/club-manage',
|
||||
memberList: '/jituan/houtai/hthqhylb',
|
||||
memberUpdate: '/jituan/houtai/htxghyxx',
|
||||
memberAdd: '/jituan/houtai/httjhy',
|
||||
memberCatalog: '/jituan/houtai/hthycatalog',
|
||||
memberEnable: '/jituan/houtai/hthysj',
|
||||
memberDisable: '/jituan/houtai/hthyxj',
|
||||
identityTagList: '/jituan/houtai/identity-tag/list',
|
||||
identityTagManage: '/jituan/houtai/identity-tag/manage',
|
||||
identityTagBindList: '/jituan/houtai/identity-tag/bind-list',
|
||||
identityTagBind: '/jituan/houtai/identity-tag/bind',
|
||||
miniappIconList: '/jituan/houtai/miniapp-icon-list',
|
||||
miniappIconManage: '/jituan/houtai/miniapp-icon-manage',
|
||||
pindaoManage: '/jituan/houtai/pindao-manage',
|
||||
clubLeixingList: '/jituan/houtai/club-leixing-list',
|
||||
clubLeixingCatalog: '/jituan/houtai/club-leixing-catalog',
|
||||
clubLeixingEnable: '/jituan/houtai/club-leixing-enable',
|
||||
clubLeixingDisable: '/jituan/houtai/club-leixing-disable',
|
||||
clubLeixingIcon: '/jituan/houtai/club-leixing-icon',
|
||||
clubKaoheList: '/jituan/houtai/club-kaohe-list',
|
||||
clubKaoheCatalog: '/jituan/houtai/club-kaohe-catalog',
|
||||
clubKaoheEnable: '/jituan/houtai/club-kaohe-enable',
|
||||
clubKaoheDisable: '/jituan/houtai/club-kaohe-disable',
|
||||
khggl: '/jituan/houtai/khggl',
|
||||
khgglAdd: '/jituan/houtai/khggl-add',
|
||||
shgxgsj: '/jituan/houtai/shgxgsj',
|
||||
dashouExamConfig: '/jituan/houtai/dashou-exam-config',
|
||||
dashouExamQuestionList: '/jituan/houtai/dashou-exam-question-list',
|
||||
dashouExamQuestionManage: '/jituan/houtai/dashou-exam-question-manage',
|
||||
dashouExamImageManage: '/jituan/houtai/dashou-exam-image-manage',
|
||||
};
|
||||
406
src/utils/kefuChat.js
Normal file
406
src/utils/kefuChat.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import GoEasy from 'goeasy'
|
||||
|
||||
export const KEFU_TEAM_ID = 'support_team'
|
||||
|
||||
const NOTIFY_DEBOUNCE_MS = 2500
|
||||
const ONLINE_TIMEOUT_MS = 12000
|
||||
|
||||
let lastNotifyAt = 0
|
||||
let onlineInFlight = null
|
||||
|
||||
export function getCsteam() {
|
||||
if (!window.goeasy?.im) return null
|
||||
return window.goeasy.im.csteam(KEFU_TEAM_ID)
|
||||
}
|
||||
|
||||
export function isGoeasyConnected() {
|
||||
if (!window.goeasy) return false
|
||||
try {
|
||||
const status = window.goeasy.getConnectionStatus()
|
||||
return status === 'connected' || status === 'reconnected'
|
||||
} catch (e) {
|
||||
return !!window.goeasy?.im
|
||||
}
|
||||
}
|
||||
|
||||
export function calcUnreadFromConversations(conversations = []) {
|
||||
return conversations.reduce((sum, c) => sum + (c.unread || 0), 0)
|
||||
}
|
||||
|
||||
/** 等待 GoEasy 内部 queryTeams 完成,初始化 teamIds,避免 online 成功回调 crash */
|
||||
function waitCsTeamsReady(csteam) {
|
||||
return new Promise((resolve) => {
|
||||
if (!csteam?.isOnline) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
csteam.isOnline({
|
||||
onSuccess: () => resolve(),
|
||||
onFailed: () => resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function checkCsOnlineStatus() {
|
||||
return new Promise((resolve) => {
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
csteam.isOnline({
|
||||
onSuccess: (online) => {
|
||||
window.__kefuCsOnline = !!online
|
||||
resolve(!!online)
|
||||
},
|
||||
onFailed: () => resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshKefuUnread(emitter) {
|
||||
if (!window.goeasy?.im) return
|
||||
const im = window.goeasy.im
|
||||
let activeUnread = 0
|
||||
|
||||
im.latestConversations({
|
||||
onSuccess: (result) => {
|
||||
const content = result.content || {}
|
||||
const convs = content.conversations || []
|
||||
const csActive = convs.filter(c => c.type === 'cs' && !c.ended)
|
||||
activeUnread = content.unreadTotal || calcUnreadFromConversations(csActive)
|
||||
|
||||
im.pendingConversations({
|
||||
onSuccess: (pendingResult) => {
|
||||
const pending = pendingResult.content?.conversations || []
|
||||
const pendingUnread = calcUnreadFromConversations(pending)
|
||||
emitter?.emit('agent-unread', activeUnread + pendingUnread)
|
||||
},
|
||||
onFailed: () => {
|
||||
emitter?.emit('agent-unread', activeUnread)
|
||||
}
|
||||
})
|
||||
},
|
||||
onFailed: () => {}
|
||||
})
|
||||
}
|
||||
|
||||
function emitCsOnline(emitter, online) {
|
||||
window.__kefuCsOnline = !!online
|
||||
emitter?.emit('kefu-cs-online', window.__kefuCsOnline)
|
||||
}
|
||||
|
||||
export function ensureCsOnline({ phone, onSuccess, onFailed, emitter, force = false } = {}) {
|
||||
if (!window.goeasy?.im) {
|
||||
onFailed?.({ content: '消息服务未连接' })
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) {
|
||||
onFailed?.({ content: '客服团队未初始化' })
|
||||
return
|
||||
}
|
||||
|
||||
const finishSuccess = () => {
|
||||
emitCsOnline(emitter, true)
|
||||
onSuccess?.()
|
||||
}
|
||||
const finishFailed = (error) => {
|
||||
emitCsOnline(emitter, false)
|
||||
onFailed?.(error)
|
||||
}
|
||||
|
||||
if (!force && window.__kefuCsOnline) {
|
||||
checkCsOnlineStatus().then((online) => {
|
||||
if (online) {
|
||||
finishSuccess()
|
||||
} else {
|
||||
window.__kefuCsOnline = false
|
||||
doCsOnline(csteam, phone, emitter, finishSuccess, finishFailed)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (force) {
|
||||
window.__kefuCsOnline = false
|
||||
onlineInFlight = null
|
||||
}
|
||||
|
||||
doCsOnline(csteam, phone, emitter, finishSuccess, finishFailed)
|
||||
}
|
||||
|
||||
function doCsOnline(csteam, phone, emitter, onSuccess, onFailed) {
|
||||
if (onlineInFlight) {
|
||||
onlineInFlight
|
||||
.then(() => onSuccess?.())
|
||||
.catch((e) => onFailed?.(e))
|
||||
return
|
||||
}
|
||||
|
||||
const displayName = phone || localStorage.getItem('username') || '客服'
|
||||
let finished = false
|
||||
|
||||
onlineInFlight = waitCsTeamsReady(csteam).then(() => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
onlineInFlight = null
|
||||
emitCsOnline(emitter, false)
|
||||
reject({ content: '上线超时,请检查网络后重试' })
|
||||
}, ONLINE_TIMEOUT_MS)
|
||||
|
||||
csteam.online({
|
||||
teamData: { name: '星阙客服中心', avatar: '' },
|
||||
agentData: { name: displayName, avatar: '' },
|
||||
onSuccess: () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
onlineInFlight = null
|
||||
emitCsOnline(emitter, true)
|
||||
resolve(true)
|
||||
},
|
||||
onFailed: (error) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
onlineInFlight = null
|
||||
const msg = (error?.content || '').toLowerCase()
|
||||
if (msg.includes('online') || msg.includes('已') || msg.includes('already')) {
|
||||
emitCsOnline(emitter, true)
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
console.error('[kefuChat] 上线失败:', error)
|
||||
emitCsOnline(emitter, false)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
onlineInFlight
|
||||
.then(() => onSuccess?.())
|
||||
.catch((e) => onFailed?.(e))
|
||||
}
|
||||
|
||||
export function goOnlineManually({ phone, emitter, onSuccess, onFailed } = {}) {
|
||||
window.__kefuUserWantsOffline = false
|
||||
ensureCsOnline({
|
||||
phone,
|
||||
emitter,
|
||||
force: true,
|
||||
onSuccess,
|
||||
onFailed
|
||||
})
|
||||
}
|
||||
|
||||
export function goOfflineManually({ emitter, onSuccess, onFailed } = {}) {
|
||||
window.__kefuUserWantsOffline = true
|
||||
onlineInFlight = null
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) {
|
||||
emitCsOnline(emitter, false)
|
||||
onSuccess?.()
|
||||
return
|
||||
}
|
||||
csteam.offline({
|
||||
onSuccess: () => {
|
||||
emitCsOnline(emitter, false)
|
||||
onSuccess?.()
|
||||
},
|
||||
onFailed: (error) => onFailed?.(error)
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectKefuChat() {
|
||||
onlineInFlight = null
|
||||
if (window.__kefuUnreadCleanup) {
|
||||
window.__kefuUnreadCleanup()
|
||||
window.__kefuUnreadCleanup = null
|
||||
}
|
||||
if (window.goeasy) {
|
||||
try {
|
||||
window.goeasy.disconnect({})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
window.goeasy = null
|
||||
}
|
||||
window.__kefuCsOnline = false
|
||||
window.__kefuUserWantsOffline = false
|
||||
window.__kefuAgentId = null
|
||||
window.__kefuAppkey = null
|
||||
}
|
||||
|
||||
export function getCsMessageCustomerId(message, teamId = KEFU_TEAM_ID, agentId) {
|
||||
if (!message) return null
|
||||
const tid = message.teamId || teamId
|
||||
const aid = agentId || window.__kefuAgentId
|
||||
const sender = message.senderId
|
||||
const to = message.to || message.receiverId
|
||||
|
||||
try {
|
||||
if (typeof message.customerId === 'function') {
|
||||
const cid = message.customerId()
|
||||
if (cid && cid !== tid) 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
|
||||
}
|
||||
if (sender === aid && to) return to
|
||||
if (to === aid && sender) return sender
|
||||
if (sender === tid && to) return to
|
||||
if (to === tid && sender) return sender
|
||||
return null
|
||||
}
|
||||
|
||||
export function isCsMessageForCustomer(message, customerId, teamId = KEFU_TEAM_ID, agentId) {
|
||||
if (!message || !customerId) return false
|
||||
if (message.teamId && message.teamId !== teamId) return false
|
||||
const cid = getCsMessageCustomerId(message, teamId, agentId)
|
||||
return cid === customerId
|
||||
}
|
||||
|
||||
export function buildCsTextMessage(teamId, customerId, customerData, text) {
|
||||
const message = window.goeasy.im.createTextMessage({
|
||||
text,
|
||||
to: {
|
||||
type: GoEasy.IM_SCENE.CS,
|
||||
id: customerId,
|
||||
data: customerData || { name: customerId, avatar: '' }
|
||||
}
|
||||
})
|
||||
message.teamId = teamId
|
||||
message.accepted = true
|
||||
return message
|
||||
}
|
||||
|
||||
export function ensureCustomerAccepted(customerId, customerData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.goeasy?.im || !customerId) {
|
||||
reject({ content: '连接未就绪' })
|
||||
return
|
||||
}
|
||||
if (!window.__kefuCsOnline) {
|
||||
reject({ content: '请先上线后再聊天' })
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) {
|
||||
reject({ content: '客服团队未初始化' })
|
||||
return
|
||||
}
|
||||
csteam.accept({
|
||||
customer: {
|
||||
id: customerId,
|
||||
data: customerData || { name: customerId, avatar: '' }
|
||||
},
|
||||
onSuccess: () => resolve(),
|
||||
onFailed: (error) => {
|
||||
const msg = (error?.content || '').toLowerCase()
|
||||
if (msg.includes('already') || msg.includes('exist') || msg.includes('已')) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function maybeNotifyNewMessage(emitter, message) {
|
||||
const aid = window.__kefuAgentId
|
||||
if (message?.senderId && message.senderId === aid) return
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastNotifyAt < NOTIFY_DEBOUNCE_MS) return
|
||||
lastNotifyAt = now
|
||||
|
||||
const customerId = getCsMessageCustomerId(message)
|
||||
const preview = message?.payload?.text || message?.payload?.name || '[新消息]'
|
||||
emitter?.emit('kefu-message-notify', {
|
||||
customerId,
|
||||
preview: String(preview).slice(0, 80),
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
function setupGlobalListeners(emitter) {
|
||||
if (!window.goeasy?.im) return () => {}
|
||||
|
||||
const im = window.goeasy.im
|
||||
const onRefresh = () => refreshKefuUnread(emitter)
|
||||
const onCsMessage = (message) => {
|
||||
emitter?.emit('kefu-cs-new-message', message)
|
||||
maybeNotifyNewMessage(emitter, message)
|
||||
refreshKefuUnread(emitter)
|
||||
}
|
||||
|
||||
im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onRefresh)
|
||||
im.on(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, onRefresh)
|
||||
im.on(GoEasy.IM_EVENT.CS_MESSAGE_RECEIVED, onCsMessage)
|
||||
|
||||
refreshKefuUnread(emitter)
|
||||
|
||||
return () => {
|
||||
im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onRefresh)
|
||||
im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, onRefresh)
|
||||
im.off(GoEasy.IM_EVENT.CS_MESSAGE_RECEIVED, onCsMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function bindGlobalUnreadListeners(emitter) {
|
||||
return setupGlobalListeners(emitter)
|
||||
}
|
||||
|
||||
export function afterGoeasyConnected(emitter, phone) {
|
||||
if (window.__kefuUnreadCleanup) {
|
||||
window.__kefuUnreadCleanup()
|
||||
}
|
||||
window.__kefuUnreadCleanup = bindGlobalUnreadListeners(emitter)
|
||||
|
||||
if (window.__kefuUserWantsOffline) {
|
||||
emitCsOnline(emitter, false)
|
||||
refreshKefuUnread(emitter)
|
||||
return
|
||||
}
|
||||
|
||||
ensureCsOnline({
|
||||
phone,
|
||||
emitter,
|
||||
force: false,
|
||||
onSuccess: () => refreshKefuUnread(emitter),
|
||||
onFailed: () => refreshKefuUnread(emitter)
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeKefuSession(emitter, phone) {
|
||||
if (!isGoeasyConnected()) {
|
||||
emitter?.emit('kefu-goeasy-ready', false)
|
||||
return
|
||||
}
|
||||
emitter?.emit('kefu-goeasy-ready', true)
|
||||
afterGoeasyConnected(emitter, phone)
|
||||
}
|
||||
|
||||
export function onGoeasyReconnected(emitter, phone) {
|
||||
emitter?.emit('kefu-goeasy-ready', true)
|
||||
if (window.__kefuUserWantsOffline) {
|
||||
emitCsOnline(emitter, false)
|
||||
refreshKefuUnread(emitter)
|
||||
return
|
||||
}
|
||||
ensureCsOnline({
|
||||
phone,
|
||||
emitter,
|
||||
force: false,
|
||||
onSuccess: () => refreshKefuUnread(emitter),
|
||||
onFailed: () => refreshKefuUnread(emitter)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
import { applyClubHeaders } from '@/utils/club-context'
|
||||
|
||||
// 创建 axios 实例,不设置 baseURL(改为动态拼接)
|
||||
const service = axios.create({
|
||||
@@ -22,7 +23,7 @@ service.interceptors.request.use(
|
||||
config.url = (window.$baseURL || '') + config.url
|
||||
}
|
||||
|
||||
return config
|
||||
return applyClubHeaders(config)
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
@@ -19,27 +19,27 @@
|
||||
|
||||
|
||||
<!-- 板块与财务 -->
|
||||
<el-sub-menu index="/finance">
|
||||
<el-sub-menu v-if="canShow('finance')" index="/finance">
|
||||
<template #title>
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>板块与财务</span>
|
||||
</template>
|
||||
<el-menu-item index="/finance/bankuai">板块配置</el-menu-item>
|
||||
<el-menu-item index="/finance/data">财务数据</el-menu-item>
|
||||
<el-menu-item v-if="canShow('finance.bankuai') && isAllClubScope" index="/finance/bankuai">板块配置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('finance.data')" index="/finance/data">财务数据</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 考核管理 -->
|
||||
<el-sub-menu index="/assessment">
|
||||
<el-sub-menu v-if="canShow('assessment')" index="/assessment">
|
||||
<template #title>
|
||||
<el-icon><Medal /></el-icon>
|
||||
<span>考核管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/assessment/examiner">考核官管理</el-menu-item>
|
||||
<el-menu-item index="/assessment/config">考核配置</el-menu-item>
|
||||
<el-menu-item index="/assessment/record">考核记录</el-menu-item>
|
||||
<el-menu-item v-if="canShow('assessment.examiner')" index="/assessment/examiner">考核官管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('assessment.config') && isAllClubScope" index="/assessment/config">考核配置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('assessment.record')" index="/assessment/record">考核记录</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
|
||||
@@ -47,109 +47,119 @@
|
||||
|
||||
|
||||
<!-- 订单管理 -->
|
||||
<el-sub-menu index="/order">
|
||||
<el-sub-menu v-if="canShow('order')" index="/order">
|
||||
<template #title>
|
||||
<el-icon><List /></el-icon>
|
||||
<span>订单管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/order/platform">平台订单</el-menu-item>
|
||||
<el-menu-item index="/order/merchant">商家订单</el-menu-item>
|
||||
<el-menu-item v-if="canShow('order.platform')" index="/order/platform">平台订单</el-menu-item>
|
||||
<el-menu-item v-if="canShow('order.merchant')" index="/order/merchant">商家订单</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 跨平台管理 -->
|
||||
<el-sub-menu index="/cross-platform">
|
||||
<el-sub-menu v-if="canShow('cross-platform')" index="/cross-platform">
|
||||
<template #title>
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>跨平台管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/cross-order">跨平台订单</el-menu-item>
|
||||
<el-menu-item index="/cross-settings">跨平台设置</el-menu-item>
|
||||
<el-menu-item index="/cross-data">跨平台数据</el-menu-item>
|
||||
<el-menu-item index="/cross-pre">预结算管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('cross-platform.order')" index="/cross-order">跨平台订单</el-menu-item>
|
||||
<el-menu-item v-if="canShow('cross-platform.settings')" index="/cross-settings">跨平台设置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('cross-platform.data')" index="/cross-data">跨平台数据</el-menu-item>
|
||||
<el-menu-item v-if="canShow('cross-platform.pre')" index="/cross-pre">预结算管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<el-sub-menu index="/user">
|
||||
<el-sub-menu v-if="canShow('user')" index="/user">
|
||||
<template #title>
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/user/player">打手管理</el-menu-item>
|
||||
<el-menu-item index="/user/guanli">管事管理</el-menu-item>
|
||||
<el-menu-item index="/user/shangjia">商家管理</el-menu-item>
|
||||
<el-menu-item index="/user/zuzhang">组长管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('user.player')" index="/user/player">打手管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('user.guanli')" index="/user/guanli">管事管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('user.shangjia')" index="/user/shangjia">商家管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('user.zuzhang')" index="/user/zuzhang">组长管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('user.identity-tag')" index="/user/identity-tag">身份装饰标签</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 提现管理 -->
|
||||
<el-sub-menu index="/withdraw">
|
||||
<el-sub-menu v-if="canShow('withdraw')" index="/withdraw">
|
||||
<template #title>
|
||||
<el-icon><Money /></el-icon>
|
||||
<span>提现管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/withdraw/audit">提现审核</el-menu-item>
|
||||
<el-menu-item index="/withdraw/data">提现数据</el-menu-item>
|
||||
<el-menu-item index="/withdraw/settings">提现设置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('withdraw.audit')" index="/withdraw/audit">提现审核</el-menu-item>
|
||||
<el-menu-item v-if="canShow('withdraw.data')" index="/withdraw/data">提现数据</el-menu-item>
|
||||
<el-menu-item v-if="canShow('withdraw.settings')" index="/withdraw/settings">提现设置</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 管理员管理 -->
|
||||
<el-sub-menu index="/admin">
|
||||
<el-sub-menu v-if="canShow('admin')" index="/admin">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>管理员管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/admin/role">角色管理</el-menu-item>
|
||||
<el-menu-item index="/admin/user">后台用户</el-menu-item>
|
||||
<el-menu-item v-if="canShow('admin.role')" index="/admin/role">角色管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('admin.data-scope')" index="/admin/data-scope">数据范围配置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('admin.club-config')" index="/admin/club-config">俱乐部密钥配置</el-menu-item>
|
||||
<el-menu-item v-if="canShow('admin.user')" index="/admin/user">后台用户</el-menu-item>
|
||||
<el-menu-item v-if="canShow('admin.operation-log')" index="/admin/operation-log">操作日志</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 处罚管理 -->
|
||||
<el-menu-item index="/punishment">
|
||||
<el-menu-item v-if="canShow('punishment')" index="/punishment">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>处罚管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 商品管理 -->
|
||||
<el-sub-menu index="/product">
|
||||
<el-sub-menu v-if="canShow('product')" index="/product">
|
||||
<template #title>
|
||||
<el-icon><Goods /></el-icon>
|
||||
<span>商品管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/product/list">商品列表</el-menu-item>
|
||||
<el-menu-item index="/product/type-zone">商品类型专区管理</el-menu-item>
|
||||
<el-menu-item index="/product/data">商品数据分析</el-menu-item>
|
||||
<el-menu-item v-if="canShow('product.list') && isAllClubScope" index="/product/list">商品列表</el-menu-item>
|
||||
<el-menu-item v-if="canShow('product.type-zone') && isAllClubScope" index="/product/type-zone">商品类型专区管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('product.data') && isAllClubScope" index="/product/data">商品数据分析</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 会员管理 -->
|
||||
<el-sub-menu index="/member">
|
||||
<el-sub-menu v-if="canShow('member')" index="/member">
|
||||
<template #title>
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>会员管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/member/list">会员管理</el-menu-item>
|
||||
<el-menu-item index="/member/data">会员数据分析</el-menu-item>
|
||||
<el-menu-item v-if="canShow('member.list')" index="/member/list">会员管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('member.data')" index="/member/data">会员数据分析</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 小程序配置 -->
|
||||
<el-sub-menu index="/miniapp">
|
||||
<el-sub-menu v-if="canShow('miniapp')" index="/miniapp">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>小程序配置</span>
|
||||
</template>
|
||||
<el-menu-item index="/miniapp/popup-notice">弹窗公告管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.carousel')" index="/miniapp/carousel">轮播图与公告</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.carousel') || canShow('miniapp.assets')" index="/miniapp/assets">图标与频道</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.popup')" index="/miniapp/popup-notice">弹窗公告管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.leixing')" index="/miniapp/leixing">抢单商品类型</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.kaohe-tags')" index="/miniapp/kaohe-tags">抢单考核标签</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.dashou-exam')" index="/miniapp/dashou-exam">打手接单考试</el-menu-item>
|
||||
<el-menu-item v-if="canShow('miniapp.withdraw-settings')" index="/withdraw/settings">提现与收款设置</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 店铺管理 -->
|
||||
<el-sub-menu index="/shop">
|
||||
<el-sub-menu v-if="canShow('shop')" index="/shop">
|
||||
<template #title>
|
||||
<el-icon><Shop /></el-icon>
|
||||
<span>店铺管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/shop/list">店铺列表</el-menu-item>
|
||||
<el-menu-item index="/shop/withdraw-apply">店铺提现申请</el-menu-item>
|
||||
<el-menu-item index="/shop/products">店铺商品管理</el-menu-item>
|
||||
<el-menu-item v-if="canShow('shop.list')" index="/shop/list">店铺列表</el-menu-item>
|
||||
<el-menu-item v-if="canShow('shop.withdraw')" index="/shop/withdraw-apply">店铺提现申请</el-menu-item>
|
||||
<el-menu-item v-if="canShow('shop.products')" index="/shop/products">店铺商品管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 会话管理(客服专用) -->
|
||||
<el-menu-item index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
|
||||
<el-menu-item v-if="canShow('chat.agent')" index="/chat/agent" :class="{ 'menu-disabled': !hasAnyChatPerm }">
|
||||
<el-icon><Comment /></el-icon>
|
||||
<span>会话管理</span>
|
||||
<el-badge
|
||||
@@ -170,21 +180,46 @@
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div class="stats-mini">
|
||||
<div v-for="stat in statsList" :key="stat.label" class="stat-mini-card">
|
||||
<div class="stat-mini-icon">{{ stat.icon }}</div>
|
||||
<div class="stat-mini-content">
|
||||
<div class="stat-mini-label">{{ stat.label }}</div>
|
||||
<div class="stat-mini-value">{{ stat.value }}</div>
|
||||
</div>
|
||||
<div class="header-row header-row-main">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ currentTitle }}</span>
|
||||
<el-select
|
||||
v-if="showScopeSelector"
|
||||
v-model="scopeSelectValue"
|
||||
size="small"
|
||||
class="club-select"
|
||||
@change="onScopeSelectChange"
|
||||
>
|
||||
<el-option
|
||||
v-if="canGroupScope"
|
||||
label="集团汇总(全部子公司)"
|
||||
value="__all__"
|
||||
/>
|
||||
<el-option
|
||||
v-for="item in clubOptions"
|
||||
:key="item.club_id"
|
||||
:label="item.name"
|
||||
:value="item.club_id"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else-if="scopeBadgeText" class="club-badge">{{ scopeBadgeText }}</span>
|
||||
<span v-if="adminRoleName" class="role-badge">{{ adminRoleName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-badge">{{ userPhone }}</span>
|
||||
<el-button class="logout-btn" size="small" @click="handleLogout" plain>退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-badge">{{ userPhone }}</span>
|
||||
<el-button class="logout-btn" size="small" @click="handleLogout" plain>退出</el-button>
|
||||
<div class="header-row header-row-stats">
|
||||
<div class="stats-mini">
|
||||
<div v-for="stat in statsList" :key="stat.label" class="stat-mini-card">
|
||||
<div class="stat-mini-icon">{{ stat.icon }}</div>
|
||||
<div class="stat-mini-content">
|
||||
<div class="stat-mini-label">{{ stat.label }}</div>
|
||||
<div class="stat-mini-value">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
@@ -196,18 +231,43 @@
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
<KefuConnect v-if="hasAnyChatPerm" />
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted, getCurrentInstance, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import {
|
||||
List, User, Warning, Money, Fold, Expand, Connection, Setting,
|
||||
Goods, UserFilled, Shop, Comment,DataAnalysis, Medal // 新增
|
||||
} from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import KefuConnect from '@/components/KefuConnect.vue'
|
||||
import { disconnectKefuChat } from '@/utils/kefuChat'
|
||||
import {
|
||||
getAdminClubContext,
|
||||
getAdminClubId,
|
||||
getAdminClubScope,
|
||||
setAdminClubContext,
|
||||
mergeServerClubContext,
|
||||
JITUAN_API,
|
||||
setMenuAccess,
|
||||
getMenuAccess,
|
||||
canSwitchClubByMenu,
|
||||
shouldShowAllMenus,
|
||||
ensureMenuAccess,
|
||||
} from '@/utils/club-context'
|
||||
|
||||
const menuAccessState = ref(getMenuAccess())
|
||||
|
||||
const canShow = (pageId) => {
|
||||
const access = menuAccessState.value ?? getMenuAccess()
|
||||
if (shouldShowAllMenus(access)) return true
|
||||
if (access.visible_page_ids.length === 0) return false
|
||||
return access.visible_page_ids.includes(pageId)
|
||||
}
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const route = useRoute()
|
||||
@@ -221,6 +281,90 @@ const toggleCollapse = () => {
|
||||
const currentTitle = computed(() => route.meta?.title || '客服终端')
|
||||
const userPhone = ref(localStorage.getItem('username') || '未知账号')
|
||||
|
||||
const clubOptions = ref([])
|
||||
const scopeSelectValue = ref('')
|
||||
|
||||
const adminCtx = computed(() => getAdminClubContext())
|
||||
const isGroupAdmin = computed(() => !!adminCtx.value?.is_group_admin)
|
||||
const canGroupScope = computed(() => isGroupAdmin.value)
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
const isSingleClubScope = computed(() => getAdminClubScope() !== 'all')
|
||||
const showScopeSelector = computed(() => canSwitchClubByMenu())
|
||||
|
||||
const scopeBadgeText = computed(() => {
|
||||
const scope = getAdminClubScope()
|
||||
if (scope === 'all') return '集团汇总(全部子公司)'
|
||||
const cid = getAdminClubId()
|
||||
const hit = clubOptions.value.find((c) => c.club_id === cid)
|
||||
return hit ? `${hit.name}(${cid})` : cid
|
||||
})
|
||||
|
||||
const adminRoleName = computed(() => adminCtx.value?.role_name || '')
|
||||
|
||||
const syncScopeSelectValue = () => {
|
||||
const scope = getAdminClubScope()
|
||||
scopeSelectValue.value = scope === 'all' ? '__all__' : getAdminClubId()
|
||||
}
|
||||
|
||||
const loadMenuAccess = async () => {
|
||||
try {
|
||||
await ensureMenuAccess(async () => {
|
||||
const res = await request.get(JITUAN_API.menuAccess)
|
||||
return res.code === 0 ? res.data : null
|
||||
})
|
||||
const data = getMenuAccess()
|
||||
if (data) {
|
||||
menuAccessState.value = data
|
||||
const ctx = getAdminClubContext()
|
||||
if (ctx) {
|
||||
setAdminClubContext({ ...ctx, can_switch_club: data.can_switch_club })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载菜单权限失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadClubContext = async () => {
|
||||
try {
|
||||
const res = await request.get(JITUAN_API.meContext)
|
||||
if (res.code === 0 && res.data) {
|
||||
mergeServerClubContext(res.data)
|
||||
clubOptions.value = res.data.clubs || []
|
||||
syncScopeSelectValue()
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载俱乐部上下文失败:', error)
|
||||
}
|
||||
const ctx = getAdminClubContext()
|
||||
if (ctx && ctx.clubs) {
|
||||
clubOptions.value = ctx.clubs
|
||||
syncScopeSelectValue()
|
||||
}
|
||||
}
|
||||
|
||||
const onScopeSelectChange = (val) => {
|
||||
const ctx = getAdminClubContext() || {}
|
||||
if (val === '__all__') {
|
||||
setAdminClubContext({
|
||||
...ctx,
|
||||
scope: 'ALL_CLUBS',
|
||||
club_id: ctx.club_id || 'xq',
|
||||
})
|
||||
ElMessage.success('已切换至:集团汇总(全部子公司)')
|
||||
} else {
|
||||
setAdminClubContext({
|
||||
...ctx,
|
||||
scope: 'SINGLE_CLUB',
|
||||
club_id: val,
|
||||
})
|
||||
const hit = clubOptions.value.find((c) => c.club_id === val)
|
||||
ElMessage.success(`已切换至:${hit?.name || val}`)
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const stats = ref({
|
||||
jinrichuli: 0,
|
||||
jinyuechuli: 0,
|
||||
@@ -256,7 +400,9 @@ const fetchStats = async () => {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
disconnectKefuChat()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
router.push('/login')
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
@@ -286,63 +432,86 @@ const fetchKefuPermission = async () => {
|
||||
const { permissions, goeasy_appkey } = res.data
|
||||
kefuPermissions.value = permissions || []
|
||||
goeasyAppkey.value = goeasy_appkey || ''
|
||||
|
||||
// 如果有权限且有appkey,初始化GoEasy(仅一次)
|
||||
if (hasAnyChatPerm.value && goeasyAppkey.value) {
|
||||
initGoEasy()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客服权限失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化GoEasy实例并连接
|
||||
const initGoEasy = () => {
|
||||
// 防止重复初始化
|
||||
if (window.goeasy) return
|
||||
|
||||
// 1. 初始化实例
|
||||
window.goeasy = GoEasy.getInstance({
|
||||
host: 'hangzhou.goeasy.io', // 根据实际区域调整
|
||||
appkey: goeasyAppkey.value,
|
||||
modules: ['im']
|
||||
})
|
||||
|
||||
// 2. 构建客服自己的userId = KF + 账号
|
||||
const agentId = 'KF' + userPhone.value
|
||||
const agentData = {
|
||||
name: userPhone.value, // 客服名称
|
||||
avatar: '' // 可后续加上头像
|
||||
}
|
||||
|
||||
// 3. 建立连接
|
||||
window.goeasy.connect({
|
||||
id: agentId,
|
||||
data: agentData,
|
||||
onSuccess: () => {
|
||||
console.log('客服GoEasy连接成功')
|
||||
// 连接成功后,通知Agent页面(通过provide)
|
||||
proxy.$emitter.emit('goeasy-connected')
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('GoEasy连接失败', error)
|
||||
ElMessage.error('消息服务连接失败,请刷新重试')
|
||||
},
|
||||
onProgress: (attempts) => {
|
||||
console.log('GoEasy正在重连,第' + attempts + '次')
|
||||
}
|
||||
})
|
||||
|
||||
// 4. 保存客服标识到全局
|
||||
window.__kefuAgentId = agentId
|
||||
}
|
||||
|
||||
// 监听子组件传来的未读消息更新
|
||||
const onAgentUnread = (count) => {
|
||||
agentUnread.value = count
|
||||
}
|
||||
|
||||
let lastUnreadForNotify = 0
|
||||
let unreadBaselineReady = false
|
||||
|
||||
const playNotifySound = () => {
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioCtx) return
|
||||
const ctx = new AudioCtx()
|
||||
const playBeep = (freq, start, duration) => {
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.frequency.value = freq
|
||||
gain.gain.value = 0.25
|
||||
osc.start(start)
|
||||
osc.stop(start + duration)
|
||||
}
|
||||
playBeep(880, ctx.currentTime, 0.12)
|
||||
playBeep(1100, ctx.currentTime + 0.15, 0.12)
|
||||
setTimeout(() => ctx.close(), 500)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const showMessageNotification = (title, message) => {
|
||||
playNotifySound()
|
||||
ElNotification({
|
||||
title,
|
||||
message,
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
showClose: true,
|
||||
position: 'top-right',
|
||||
customClass: 'kefu-msg-notify',
|
||||
onClick: () => {
|
||||
ElNotification.closeAll()
|
||||
router.push('/chat/agent')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onMessageNotify = (payload) => {
|
||||
if (route.path === '/chat/agent') return
|
||||
const customerId = payload?.customerId || '用户'
|
||||
const preview = payload?.preview || '收到新消息'
|
||||
showMessageNotification('新会话消息', `${customerId}: ${preview}`)
|
||||
}
|
||||
|
||||
const onUnreadBumpNotify = (count) => {
|
||||
if (!unreadBaselineReady) {
|
||||
lastUnreadForNotify = count
|
||||
unreadBaselineReady = true
|
||||
return
|
||||
}
|
||||
if (route.path === '/chat/agent') {
|
||||
lastUnreadForNotify = count
|
||||
return
|
||||
}
|
||||
if (count <= 0) {
|
||||
lastUnreadForNotify = 0
|
||||
return
|
||||
}
|
||||
if (count > lastUnreadForNotify) {
|
||||
showMessageNotification('会话未读提醒', `您有 ${count} 条未读消息,点击查看`)
|
||||
}
|
||||
lastUnreadForNotify = count
|
||||
}
|
||||
|
||||
// 向子组件提供客服相关数据
|
||||
provide('kefuPermissions', kefuPermissions)
|
||||
provide('permPrefixMap', permPrefixMap)
|
||||
@@ -350,16 +519,22 @@ provide('goeasyAppkey', goeasyAppkey)
|
||||
provide('hasAnyChatPerm', hasAnyChatPerm)
|
||||
|
||||
onMounted(() => {
|
||||
loadMenuAccess()
|
||||
loadClubContext()
|
||||
fetchStats()
|
||||
fetchKefuPermission() // 登录后先获取权限
|
||||
fetchKefuPermission()
|
||||
proxy.$emitter?.on('refresh-stats', fetchStats)
|
||||
proxy.$emitter?.on('agent-unread', onAgentUnread)
|
||||
proxy.$emitter?.on('agent-unread', onUnreadBumpNotify)
|
||||
proxy.$emitter?.on('kefu-message-notify', onMessageNotify)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (abortController) abortController.abort()
|
||||
proxy.$emitter?.off('refresh-stats', fetchStats)
|
||||
proxy.$emitter?.off('agent-unread', onAgentUnread)
|
||||
proxy.$emitter?.off('agent-unread', onUnreadBumpNotify)
|
||||
proxy.$emitter?.off('kefu-message-notify', onMessageNotify)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -501,19 +676,67 @@ onUnmounted(() => {
|
||||
background: rgba(6, 12, 20, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 242, 255, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 24px 12px;
|
||||
gap: 10px;
|
||||
height: auto;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-row-main {
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 80px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-row-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.club-select {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.club-badge {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 242, 255, 0.9);
|
||||
border: 1px solid rgba(0, 242, 255, 0.35);
|
||||
background: rgba(0, 242, 255, 0.08);
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 242, 255, 0.85);
|
||||
border: 1px solid rgba(0, 242, 255, 0.35);
|
||||
background: rgba(0, 242, 255, 0.08);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
@@ -529,9 +752,10 @@ onUnmounted(() => {
|
||||
.stats-mini {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.stat-mini-card {
|
||||
@@ -640,15 +864,13 @@ onUnmounted(() => {
|
||||
/* 响应式 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.header {
|
||||
height: auto;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.header-row-main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stats-mini {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stat-mini-card {
|
||||
padding: 6px 16px;
|
||||
@@ -681,4 +903,22 @@ onUnmounted(() => {
|
||||
.agent-badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.kefu-msg-notify {
|
||||
min-width: 320px;
|
||||
border-left: 4px solid #409eff !important;
|
||||
box-shadow: 0 4px 20px rgba(64, 158, 255, 0.35) !important;
|
||||
}
|
||||
.kefu-msg-notify .el-notification__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
.kefu-msg-notify .el-notification__content {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -65,6 +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'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
@@ -92,9 +93,9 @@ const handleLogin = async () => {
|
||||
try {
|
||||
// 直接使用 axios 发送请求,baseURL 从 window.$baseURL 获取
|
||||
const response = await axios.post(
|
||||
window.$baseURL + '/yonghu/kefujinru',
|
||||
window.$baseURL + JITUAN_API.kefuLogin,
|
||||
{
|
||||
phone: form.username, // 字段与后端一致
|
||||
phone: form.username,
|
||||
password: form.password,
|
||||
erjimima: form.ip
|
||||
},
|
||||
@@ -109,12 +110,22 @@ const handleLogin = async () => {
|
||||
const res = response.data
|
||||
|
||||
if (res.code === 0) {
|
||||
// 登录成功
|
||||
const token = res.data.token
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('username', form.username)
|
||||
if (res.data.club_context) {
|
||||
const ctx = { ...res.data.club_context }
|
||||
if (res.data.menu_access) {
|
||||
setMenuAccess(res.data.menu_access)
|
||||
ctx.can_switch_club = res.data.menu_access.can_switch_club
|
||||
}
|
||||
setAdminClubContext(ctx)
|
||||
} else if (res.data.menu_access) {
|
||||
setMenuAccess(res.data.menu_access)
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
const firstPath = res.data.menu_access?.visible_paths?.[0]
|
||||
router.push(firstPath || '/order/platform')
|
||||
} else if (res.code === 4) {
|
||||
// 账号被封禁
|
||||
ElMessage.error('账号已被封禁')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="punishment">
|
||||
<!-- ==================== 顶部类型切换 ==================== -->
|
||||
<ClubScopeHint />
|
||||
<div class="type-switch">
|
||||
<el-radio-group v-model="activeType" size="large">
|
||||
<el-radio-group v-model="activeType" size="large" @change="onTypeChange">
|
||||
<el-radio-button value="jifen">积分处罚</el-radio-button>
|
||||
<el-radio-button value="fakuan">罚款审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -21,7 +21,7 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<el-card class="stat-card" shadow="hover" @click="switchStatusTab('pending')">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">待处理</div>
|
||||
<div class="stat-value warning">{{ stats.daichuli || 0 }}</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<el-card class="stat-card" shadow="hover" @click="switchStatusTab('processed')">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value success">{{ stats.yichuli || 0 }}</div>
|
||||
@@ -55,6 +55,7 @@
|
||||
</el-row>
|
||||
|
||||
<el-tabs v-model="activeStatus" class="status-tabs" @tab-change="handleStatusChange">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="待处理" name="pending" />
|
||||
<el-tab-pane label="已处理" name="processed" />
|
||||
</el-tabs>
|
||||
@@ -105,6 +106,8 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
import FadanManagement from '@/views/punishment/components/FadanManagement.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -122,11 +125,12 @@ const pageSize = ref(20)
|
||||
const stats = ref({ zongshu: 0, daichuli: 0, yichuli: 0 })
|
||||
|
||||
const filters = reactive({ dingdan_id: '', dashouid: '' })
|
||||
const activeStatus = ref('pending')
|
||||
const activeStatus = ref('all')
|
||||
|
||||
const getStatusArray = () => {
|
||||
if (activeStatus.value === 'all') return undefined
|
||||
if (activeStatus.value === 'pending') return [0, 3]
|
||||
else return [1, 2]
|
||||
return [1, 2]
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -136,15 +140,20 @@ const fetchData = async () => {
|
||||
phone: localStorage.getItem('username'),
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: getStatusArray(),
|
||||
dingdan_id: filters.dingdan_id || undefined,
|
||||
dashouid: filters.dashouid || undefined
|
||||
}
|
||||
const statusArr = getStatusArray()
|
||||
if (statusArr) params.status = statusArr
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key])
|
||||
const res = await request.post('/yonghu/kefu_cfgl', params)
|
||||
const res = await request.post(JITUAN_API.kefuCfgl, params)
|
||||
if (res.code === 0) {
|
||||
const data = res.data
|
||||
list.value = data.list || []
|
||||
list.value = (data.list || []).map((item) => ({
|
||||
...item,
|
||||
create_time: item.create_time || item.CreateTime || '',
|
||||
update_time: item.update_time || item.UpdateTime || '',
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
stats.value = {
|
||||
zongshu: data.stats?.zongshu || 0,
|
||||
@@ -166,6 +175,13 @@ const handleSearch = () => { page.value = 1; fetchData() }
|
||||
const resetFilters = () => { filters.dingdan_id = ''; filters.dashouid = ''; handleSearch() }
|
||||
const refreshList = () => fetchData()
|
||||
const handleStatusChange = () => { page.value = 1; fetchData() }
|
||||
const switchStatusTab = (tab) => {
|
||||
const map = { pending: 'pending', processed: 'processed' }
|
||||
const next = map[tab]
|
||||
if (!next || activeStatus.value === next) return
|
||||
activeStatus.value = next
|
||||
handleStatusChange()
|
||||
}
|
||||
const handleSizeChange = (val) => { pageSize.value = val; page.value = 1; fetchData() }
|
||||
const handlePageChange = (val) => { page.value = val; fetchData() }
|
||||
|
||||
@@ -189,6 +205,10 @@ const getStatusType = (status) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const onTypeChange = (val) => {
|
||||
if (val === 'jifen') fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (activeType.value === 'jifen') fetchData()
|
||||
})
|
||||
@@ -198,7 +218,7 @@ onMounted(() => {
|
||||
.punishment { padding: 20px; background-color: #f5f9fc; min-height: 100%; }
|
||||
.type-switch { margin-bottom: 20px; text-align: center; }
|
||||
.stats-cards { margin-bottom: 20px; }
|
||||
.stat-card { border-radius: 12px; transition: transform 0.2s; }
|
||||
.stat-card { border-radius: 12px; transition: transform 0.2s; cursor: pointer; }
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 16px rgba(0,0,0,0.05); }
|
||||
.stat-item { text-align: center; padding: 10px 0; }
|
||||
.stat-label { font-size: 14px; color: #6b7a88; margin-bottom: 8px; }
|
||||
|
||||
269
src/views/admin/ClubConfig.vue
Normal file
269
src/views/admin/ClubConfig.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="club-config-page">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="俱乐部主数据与小程序/支付/GoEasy 密钥"
|
||||
description="仅超级管理员可编辑。修改后影响该俱乐部小程序登录、支付回调、IM 等,请谨慎操作。"
|
||||
class="tip"
|
||||
/>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select v-model="currentClubId" placeholder="选择俱乐部" @change="loadClub" style="width: 200px">
|
||||
<el-option v-for="c in clubList" :key="c.club_id" :label="c.name" :value="c.club_id" />
|
||||
</el-select>
|
||||
<el-button type="success" @click="openCreateDialog">新建俱乐部</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveClub">保存配置</el-button>
|
||||
<el-button :icon="Refresh" circle @click="loadClub" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建俱乐部 / 小程序" width="480px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="俱乐部 ID" required>
|
||||
<el-input v-model="createForm.new_club_id" placeholder="如 xy、al(与小程序 club-config 一致)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="展示名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="如:逍遥梦俱乐部" />
|
||||
</el-form-item>
|
||||
<el-form-item label="小程序 AppID">
|
||||
<el-input v-model="createForm.wx_appid" placeholder="新建小程序的 AppID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="复制模板">
|
||||
<el-select v-model="createForm.from_club" style="width: 100%">
|
||||
<el-option v-for="c in clubList" :key="c.club_id" :label="c.name" :value="c.club_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="createClub">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-form v-loading="loading" :model="form" label-width="140px" 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" />
|
||||
</el-form-item>
|
||||
<el-form-item label="wx_secret">
|
||||
<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" />
|
||||
</el-form-item>
|
||||
<el-form-item label="pay_app_id">
|
||||
<el-input v-model="form.pay_app_id" />
|
||||
</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>
|
||||
<el-form-item label="cert_serial_no">
|
||||
<el-input v-model="form.cert_serial_no" />
|
||||
</el-form-item>
|
||||
<el-form-item label="private_key_path">
|
||||
<el-input v-model="form.private_key_path" placeholder="服务器上私钥文件路径" />
|
||||
</el-form-item>
|
||||
<el-form-item label="platform_cert_dir">
|
||||
<el-input v-model="form.platform_cert_dir" placeholder="平台证书目录" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">服务号</el-divider>
|
||||
<el-form-item label="official_appid">
|
||||
<el-input v-model="form.official_appid" />
|
||||
</el-form-item>
|
||||
<el-form-item label="official_secret">
|
||||
<el-input v-model="form.official_secret" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="official_token">
|
||||
<el-input v-model="form.official_token" />
|
||||
</el-form-item>
|
||||
<el-form-item label="encoding_aes_key">
|
||||
<el-input v-model="form.encoding_aes_key" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">GoEasy / OSS / 域名</el-divider>
|
||||
<el-form-item label="goeasy_appkey">
|
||||
<el-input v-model="form.goeasy_appkey" />
|
||||
</el-form-item>
|
||||
<el-form-item label="goeasy_secret">
|
||||
<el-input v-model="form.goeasy_secret" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="oss_prefix">
|
||||
<el-input v-model="form.oss_prefix" placeholder="同一 COS 桶内目录前缀,如 xq/" />
|
||||
</el-form-item>
|
||||
<el-form-item label="h5_domain">
|
||||
<el-input v-model="form.h5_domain" placeholder="商家 H5 域名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="template_id">
|
||||
<el-input v-model="form.template_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="展示名称">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const creating = ref(false)
|
||||
const createVisible = ref(false)
|
||||
const clubList = ref([])
|
||||
const currentClubId = ref('xq')
|
||||
const createForm = reactive({
|
||||
new_club_id: '',
|
||||
name: '',
|
||||
wx_appid: '',
|
||||
from_club: 'xq',
|
||||
})
|
||||
const form = reactive({
|
||||
name: '',
|
||||
wx_appid: '',
|
||||
wx_secret: '',
|
||||
mch_id: '',
|
||||
pay_app_id: '',
|
||||
api_v3_key: '',
|
||||
cert_serial_no: '',
|
||||
private_key_path: '',
|
||||
platform_cert_dir: '',
|
||||
official_appid: '',
|
||||
official_secret: '',
|
||||
official_token: '',
|
||||
encoding_aes_key: '',
|
||||
goeasy_appkey: '',
|
||||
goeasy_secret: '',
|
||||
oss_prefix: '',
|
||||
h5_domain: '',
|
||||
template_id: '',
|
||||
template_max_per_minute: 950,
|
||||
sort_order: 0,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const loadClubList = async () => {
|
||||
const res = await request.post(JITUAN_API.clubManage, { phone, action: 'list' })
|
||||
if (res.code === 0) {
|
||||
clubList.value = res.data || []
|
||||
if (!currentClubId.value && clubList.value.length) {
|
||||
currentClubId.value = clubList.value[0].club_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadClub = async () => {
|
||||
if (!currentClubId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubManage, {
|
||||
phone,
|
||||
action: 'get',
|
||||
club_id: currentClubId.value,
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载俱乐部配置失败,请确认已部署最新后端且账号为超级管理员')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveClub = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubManage, {
|
||||
phone,
|
||||
action: 'update',
|
||||
club_id: currentClubId.value,
|
||||
...form,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
await loadClubList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
createForm.new_club_id = ''
|
||||
createForm.name = ''
|
||||
createForm.wx_appid = ''
|
||||
createForm.from_club = currentClubId.value || 'xq'
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
const createClub = async () => {
|
||||
if (!createForm.new_club_id || !createForm.name) {
|
||||
ElMessage.warning('请填写俱乐部 ID 和名称')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubManage, {
|
||||
phone,
|
||||
action: 'create',
|
||||
new_club_id: createForm.new_club_id.trim(),
|
||||
name: createForm.name.trim(),
|
||||
wx_appid: createForm.wx_appid.trim(),
|
||||
from_club: createForm.from_club,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('俱乐部创建成功,请继续填写密钥并保存')
|
||||
createVisible.value = false
|
||||
await loadClubList()
|
||||
currentClubId.value = createForm.new_club_id.trim()
|
||||
await loadClub()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('创建失败,请确认账号有 000001 权限且后端已部署')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClubList()
|
||||
await loadClub()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.club-config-page {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.config-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
</style>
|
||||
534
src/views/admin/DataScope.vue
Normal file
534
src/views/admin/DataScope.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
|
||||
<div class="data-scope-page">
|
||||
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="scope-tip"
|
||||
title="操作权限在「角色管理」里配"
|
||||
description="查看会员=3300a、财务=caiwu、订单=002ab 等:角色管理 → 角色详情 → 添加权限。给账号绑角色:管理员用户。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="scope-tip"
|
||||
title="集团高管 / 查看全部订单与用户"
|
||||
description="功能权限:角色管理里给「订单 002ab」「用户列表」等权限。数据范围:本页添加任职,范围选「全部俱乐部 ALL_CLUBS」,登录后顶栏切「集团汇总」即可看全部订单/财务/用户(统计按各俱乐部汇总,不串单俱乐部数据)。"
|
||||
/>
|
||||
|
||||
<div class="action-bar">
|
||||
|
||||
<el-tag type="info" size="large">任职记录 {{ list.length }} 条</el-tag>
|
||||
|
||||
<div class="action-right">
|
||||
|
||||
<el-button v-if="canManage" type="primary" @click="openAssignDialog">添加任职</el-button>
|
||||
|
||||
<el-button :icon="Refresh" circle @click="fetchList" :loading="loading" />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
|
||||
<el-table-column prop="phone" label="后台账号" width="140" />
|
||||
|
||||
<el-table-column prop="yonghuid" label="用户ID" width="100" />
|
||||
|
||||
<el-table-column prop="club_label" label="数据范围" min-width="160" />
|
||||
|
||||
<el-table-column prop="role_name" label="任职角色" width="160" />
|
||||
|
||||
<el-table-column prop="data_scope" label="范围类型" width="120">
|
||||
|
||||
<template #default="{ row }">
|
||||
|
||||
{{ row.data_scope === 'ALL_CLUBS' ? '集团汇总' : '单俱乐部' }}
|
||||
|
||||
</template>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="主任职" width="90" align="center">
|
||||
|
||||
<template #default="{ row }">
|
||||
|
||||
<el-tag v-if="row.is_primary" type="success" size="small">是</el-tag>
|
||||
|
||||
<span v-else>—</span>
|
||||
|
||||
</template>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="canManage" label="操作" width="100" align="center">
|
||||
|
||||
<template #default="{ row }">
|
||||
|
||||
<el-button type="danger" link @click="removeAssignment(row)">停用</el-button>
|
||||
|
||||
</template>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
|
||||
|
||||
|
||||
<el-card v-if="currentContext" class="context-card" shadow="never">
|
||||
|
||||
<template #header>当前登录上下文</template>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
|
||||
<el-descriptions-item label="任职角色">{{ currentContext.role_name }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="数据范围">
|
||||
|
||||
{{ currentContext.scope === 'ALL_CLUBS' ? '集团汇总' : `子公司 ${currentContext.club_id}` }}
|
||||
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="可切换俱乐部">
|
||||
|
||||
{{ currentContext.can_switch_club ? '是' : '否' }}
|
||||
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="集团管理员">
|
||||
|
||||
{{ currentContext.is_group_admin ? '是' : '否' }}
|
||||
|
||||
</el-descriptions-item>
|
||||
|
||||
</el-descriptions>
|
||||
|
||||
</el-card>
|
||||
|
||||
|
||||
|
||||
<el-dialog v-model="assignVisible" title="添加数据范围任职" width="480px">
|
||||
|
||||
<el-form label-width="120px">
|
||||
|
||||
<el-form-item label="后台手机号" required>
|
||||
|
||||
<el-input v-model="assignForm.target_phone" placeholder="客服登录手机号" />
|
||||
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="数据范围">
|
||||
|
||||
<el-select v-model="assignForm.club_id" placeholder="留空=集团全部" clearable style="width: 100%">
|
||||
|
||||
<el-option label="集团(全部子公司)" value="" />
|
||||
|
||||
<el-option
|
||||
|
||||
v-for="c in clubOptions"
|
||||
|
||||
:key="c.club_id"
|
||||
|
||||
:label="c.name"
|
||||
|
||||
:value="c.club_id"
|
||||
|
||||
/>
|
||||
|
||||
</el-select>
|
||||
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="任职角色">
|
||||
<el-select
|
||||
v-model="assignForm.role_code"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="可选预设或自定义输入(仅标签,不控制菜单)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="集团总负责人" value="GROUP_OWNER" />
|
||||
<el-option label="集团超管" value="GROUP_SUPER_ADMIN" />
|
||||
<el-option label="集团财务" value="GROUP_FINANCE" />
|
||||
<el-option label="集团售后" value="GROUP_AFTER_SALES" />
|
||||
<el-option label="俱乐部总负责人" value="CLUB_OWNER" />
|
||||
<el-option label="俱乐部管理员" value="CLUB_ADMIN" />
|
||||
<el-option label="俱乐部财务" value="CLUB_FINANCE" />
|
||||
<el-option label="俱乐部售后" value="CLUB_AFTER_SALES" />
|
||||
<el-option label="俱乐部运营" value="CLUB_OPERATOR" />
|
||||
</el-select>
|
||||
<div class="role-hint">任职角色只是备注标签,<strong>不能</strong>替代「角色管理」里的功能权限。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主任职">
|
||||
|
||||
<el-switch v-model="assignForm.is_primary" />
|
||||
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
|
||||
<el-button type="primary" :loading="assigning" @click="submitAssign">保存</el-button>
|
||||
|
||||
</template>
|
||||
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
import request from '@/utils/request'
|
||||
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const assigning = ref(false)
|
||||
|
||||
const assignVisible = ref(false)
|
||||
|
||||
const list = ref([])
|
||||
|
||||
const permNote = ref('')
|
||||
|
||||
const currentContext = ref(null)
|
||||
|
||||
const canManage = ref(false)
|
||||
|
||||
const clubOptions = ref([])
|
||||
|
||||
|
||||
|
||||
const loadClubOptionsForAssign = async () => {
|
||||
|
||||
try {
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const res = await request.post(JITUAN_API.clubManage, { phone, action: 'list' })
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data?.list)) {
|
||||
|
||||
clubOptions.value = res.data.list
|
||||
|
||||
.filter((c) => c.status !== 0)
|
||||
|
||||
.map((c) => ({ club_id: c.club_id, name: c.name }))
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
if (currentContext.value?.clubs?.length) {
|
||||
|
||||
clubOptions.value = currentContext.value.clubs
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const assignForm = reactive({
|
||||
|
||||
target_phone: '',
|
||||
|
||||
club_id: '',
|
||||
|
||||
role_code: 'CLUB_ADMIN',
|
||||
|
||||
is_primary: false,
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
const fetchList = async () => {
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const res = await request.post(JITUAN_API.adminAssignments, { phone })
|
||||
|
||||
if (res.code === 0) {
|
||||
|
||||
list.value = res.data?.list || []
|
||||
|
||||
permNote.value = res.data?.perm_note || ''
|
||||
|
||||
currentContext.value = res.data?.current_context || null
|
||||
|
||||
canManage.value = res.data?.can_manage || false
|
||||
|
||||
clubOptions.value = currentContext.value?.clubs || []
|
||||
|
||||
if (canManage.value) {
|
||||
|
||||
await loadClubOptionsForAssign()
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
|
||||
} catch (err) {
|
||||
|
||||
try {
|
||||
|
||||
const ctxRes = await request.get(JITUAN_API.meContext)
|
||||
|
||||
if (ctxRes.code === 0 && ctxRes.data) {
|
||||
|
||||
currentContext.value = ctxRes.data
|
||||
|
||||
clubOptions.value = ctxRes.data.clubs || []
|
||||
|
||||
permNote.value = '任职列表接口暂不可用;请部署最新后端并执行 migrate jituan。'
|
||||
|
||||
ElMessage.warning('任职列表加载失败,已显示当前登录上下文')
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
console.error(err)
|
||||
|
||||
ElMessage.error('加载失败,请确认已部署 /jituan/houtai/admin-assignments')
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const openAssignDialog = async () => {
|
||||
|
||||
assignForm.target_phone = ''
|
||||
|
||||
assignForm.club_id = ''
|
||||
|
||||
assignForm.role_code = 'CLUB_ADMIN'
|
||||
|
||||
assignForm.is_primary = false
|
||||
|
||||
if (canManage.value) {
|
||||
|
||||
await loadClubOptionsForAssign()
|
||||
|
||||
}
|
||||
|
||||
assignVisible.value = true
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const submitAssign = async () => {
|
||||
|
||||
if (!assignForm.target_phone) {
|
||||
|
||||
ElMessage.warning('请填写手机号')
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
assigning.value = true
|
||||
|
||||
try {
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const res = await request.post(JITUAN_API.adminAssignments, {
|
||||
|
||||
phone,
|
||||
|
||||
action: 'create',
|
||||
|
||||
target_phone: assignForm.target_phone.trim(),
|
||||
|
||||
club_id: assignForm.club_id || null,
|
||||
|
||||
role_code: assignForm.role_code,
|
||||
|
||||
is_primary: assignForm.is_primary,
|
||||
|
||||
data_scope: assignForm.club_id ? 'SINGLE_CLUB' : 'ALL_CLUBS',
|
||||
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
|
||||
ElMessage.success('任职已添加')
|
||||
|
||||
assignVisible.value = false
|
||||
|
||||
await fetchList()
|
||||
|
||||
} else {
|
||||
|
||||
ElMessage.error(res.msg || '添加失败')
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
ElMessage.error('添加失败')
|
||||
|
||||
} finally {
|
||||
|
||||
assigning.value = false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const removeAssignment = (row) => {
|
||||
|
||||
ElMessageBox.confirm(`停用 ${row.phone} 的任职?`, '提示', { type: 'warning' })
|
||||
|
||||
.then(async () => {
|
||||
|
||||
const phone = localStorage.getItem('username')
|
||||
|
||||
const res = await request.post(JITUAN_API.adminAssignments, {
|
||||
|
||||
phone,
|
||||
|
||||
action: 'delete',
|
||||
|
||||
id: row.id,
|
||||
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
|
||||
ElMessage.success('已停用')
|
||||
|
||||
list.value = list.value.filter((item) => item.id !== row.id)
|
||||
|
||||
await fetchList()
|
||||
|
||||
} else {
|
||||
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.data-scope-page {
|
||||
|
||||
padding: 0 4px;
|
||||
|
||||
}
|
||||
|
||||
.scope-tip {
|
||||
|
||||
margin-bottom: 16px;
|
||||
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
margin-bottom: 12px;
|
||||
|
||||
}
|
||||
|
||||
.action-right {
|
||||
|
||||
display: flex;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.context-card {
|
||||
|
||||
margin-top: 20px;
|
||||
|
||||
}
|
||||
|
||||
.role-hint {
|
||||
font-size: 12px;
|
||||
color: #8ab3cf;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
312
src/views/admin/OperationLog.vue
Normal file
312
src/views/admin/OperationLog.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="operation-log">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问操作日志</div>
|
||||
<div class="no-permission-desc">需要 000001 或 czrz666 权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="page-header">
|
||||
<h2>操作日志</h2>
|
||||
<span class="subtitle">后台对用户/商家/管事/组长等数据的修改记录</span>
|
||||
</div>
|
||||
|
||||
<div class="log-type-tabs">
|
||||
<span
|
||||
class="log-type-tab"
|
||||
:class="{ active: logType === 'xiugai' }"
|
||||
@click="switchLogType('xiugai')"
|
||||
>修改记录</span>
|
||||
<span
|
||||
class="log-type-tab"
|
||||
:class="{ active: logType === 'audit' }"
|
||||
@click="switchLogType('audit')"
|
||||
>审计日志</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" v-if="logType === 'xiugai'">
|
||||
<el-input v-model="filters.yonghuid" placeholder="被修改用户ID" clearable class="filter-item" />
|
||||
<el-input v-model="filters.xiugaiid" placeholder="操作人账号" clearable class="filter-item" />
|
||||
<el-select v-model="filters.leixing" placeholder="用户类型" clearable class="filter-item">
|
||||
<el-option label="打手" :value="2" />
|
||||
<el-option label="管事" :value="3" />
|
||||
<el-option label="商家" :value="4" />
|
||||
<el-option label="组长" :value="5" />
|
||||
</el-select>
|
||||
<el-input v-model="filters.keyword" placeholder="搜索操作说明" clearable class="filter-item wide" />
|
||||
<el-button type="primary" @click="handleSearch" :loading="loading">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" v-else>
|
||||
<el-input v-model="auditFilters.target_yonghuid" placeholder="目标用户ID" clearable class="filter-item" />
|
||||
<el-input v-model="auditFilters.operator_yonghuid" placeholder="操作人ID" clearable class="filter-item" />
|
||||
<el-input v-model="auditFilters.keyword" placeholder="搜索备注" clearable class="filter-item wide" />
|
||||
<el-button type="primary" @click="handleSearch" :loading="loading">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-if="logType === 'xiugai'" :data="list" v-loading="loading" stripe border class="log-table">
|
||||
<el-table-column prop="create_time" label="时间" width="170" />
|
||||
<el-table-column prop="leixing_label" label="类型" width="80" />
|
||||
<el-table-column prop="yonghuid" label="被修改用户" width="100" />
|
||||
<el-table-column prop="xiugaiid" label="操作人" width="130" />
|
||||
<el-table-column prop="qitashuoming" label="操作说明" min-width="360">
|
||||
<template #default="{ row }">
|
||||
<div class="desc-cell">{{ row.qitashuoming || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额/积分变动" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="amount-cell">
|
||||
<div v-if="row.xiugaitijiaoq !== null && row.xiugaitijiao !== null">
|
||||
余额: {{ row.xiugaitijiaoq }} → {{ row.xiugaitijiao }}
|
||||
</div>
|
||||
<div v-if="row.shangjiayueq !== null && row.shangjiayue !== null && row.shangjiayueq !== '0.00'">
|
||||
商家余额: {{ row.shangjiayueq }} → {{ row.shangjiayue }}
|
||||
</div>
|
||||
<div v-if="row.guanshiyueq !== null && row.guanshiyue !== null && row.guanshiyueq !== '0.00'">
|
||||
管事余额: {{ row.guanshiyueq }} → {{ row.guanshiyue }}
|
||||
</div>
|
||||
<div v-if="row.yjifen || row.jifen">
|
||||
积分: {{ row.yjifen }} → {{ row.jifen }}
|
||||
</div>
|
||||
<div v-if="row.yyajin !== null && row.yajin !== null && row.yyajin !== '0.00'">
|
||||
押金: {{ row.yyajin }} → {{ row.yajin }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table v-else :data="auditList" v-loading="loading" stripe border class="log-table">
|
||||
<el-table-column prop="CreateTime" label="时间" width="170" />
|
||||
<el-table-column prop="club_id" label="俱乐部" width="90" />
|
||||
<el-table-column prop="operator_yonghuid" label="操作人" width="100" />
|
||||
<el-table-column prop="target_yonghuid" label="目标用户" width="100" />
|
||||
<el-table-column prop="action" label="动作" width="100" />
|
||||
<el-table-column prop="remark" label="备注" min-width="360">
|
||||
<template #default="{ row }">
|
||||
<div class="desc-cell">{{ row.remark || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_ip" label="IP" width="130" />
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchList"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, inject } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const kefuPermissions = inject('kefuPermissions', ref([]))
|
||||
const logType = ref('xiugai')
|
||||
const hasPermission = computed(() => {
|
||||
const perms = kefuPermissions.value || []
|
||||
return perms.includes('000001') || perms.includes('czrz666')
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const auditList = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const filters = reactive({
|
||||
yonghuid: '',
|
||||
xiugaiid: '',
|
||||
leixing: null,
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
const auditFilters = reactive({
|
||||
target_yonghuid: '',
|
||||
operator_yonghuid: '',
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
const fetchXiugaiList = async () => {
|
||||
const res = await request.post(JITUAN_API.operationLog, {
|
||||
username: username.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
yonghuid: filters.yonghuid.trim(),
|
||||
xiugaiid: filters.xiugaiid.trim(),
|
||||
leixing: filters.leixing,
|
||||
keyword: filters.keyword.trim()
|
||||
})
|
||||
if (res.code === 0) {
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAuditList = async () => {
|
||||
const res = await request.post(JITUAN_API.auditLog, {
|
||||
username: username.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
target_yonghuid: auditFilters.target_yonghuid.trim(),
|
||||
operator_yonghuid: auditFilters.operator_yonghuid.trim(),
|
||||
keyword: auditFilters.keyword.trim()
|
||||
})
|
||||
if (res.code === 0) {
|
||||
auditList.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取审计日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!hasPermission.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (logType.value === 'audit') {
|
||||
await fetchAuditList()
|
||||
} else {
|
||||
await fetchXiugaiList()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('获取日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchLogType = (type) => {
|
||||
if (logType.value === type) return
|
||||
logType.value = type
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (logType.value === 'audit') {
|
||||
auditFilters.target_yonghuid = ''
|
||||
auditFilters.operator_yonghuid = ''
|
||||
auditFilters.keyword = ''
|
||||
} else {
|
||||
filters.yonghuid = ''
|
||||
filters.xiugaiid = ''
|
||||
filters.leixing = null
|
||||
filters.keyword = ''
|
||||
}
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasPermission.value) fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-log {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.log-type-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.log-type-tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
.log-type-tab.active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-item {
|
||||
width: 160px;
|
||||
}
|
||||
.filter-item.wide {
|
||||
width: 220px;
|
||||
}
|
||||
.desc-cell {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
.amount-cell {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.pagination-wrap {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.no-permission {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.no-permission-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.no-permission-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.no-permission-desc {
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,14 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="manager-container">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="scope-hint"
|
||||
title="角色按当前顶栏俱乐部隔离"
|
||||
description="在「星之界 xzj」视图下添加的角色仅 xzj 可见;权限码(caiwu、002ab 等)全局共用。集团高管:顶栏选「集团汇总」+ 数据范围配 ALL_CLUBS,并给角色订单/用户/财务权限。"
|
||||
/>
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="action-bar">
|
||||
<div class="stats-tip">
|
||||
@@ -23,7 +31,8 @@
|
||||
<div v-for="role in roleList" :key="role.role_code" class="role-card">
|
||||
<div class="card-header">
|
||||
<div class="role-name">{{ role.role_name }}</div>
|
||||
<!-- 不展示角色编码 -->
|
||||
<el-tag v-if="role.club_id" size="small" type="info">{{ role.club_id }}</el-tag>
|
||||
<el-tag v-else size="small">集团全局</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="desc">{{ role.description || '暂无描述' }}</div>
|
||||
@@ -446,6 +455,9 @@ onMounted(() => {
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.scope-hint {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.manager-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAllClubScope" class="scope-block">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前为具体小程序视图"
|
||||
description="此处不可新增/编辑/删除全局考核称号。请切换顶栏为「集团汇总」后使用本页;或在「小程序配置 → 抢单考核标签」中仅做上架/下架。"
|
||||
/>
|
||||
<el-button type="primary" link @click="$router.push('/miniapp/kaohe-tags')">前往抢单考核标签</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 板块选择 + 添加按钮 -->
|
||||
<div class="section-header">
|
||||
@@ -113,6 +124,9 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { getAdminClubScope } from '@/utils/club-context'
|
||||
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
const hasPermission = ref(true)
|
||||
|
||||
@@ -1,8 +1,603 @@
|
||||
<template>
|
||||
<div class="exam-record-page">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<el-empty description="无考核管理权限,请联系管理员开通 kaohepeizhi" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 统计概览 -->
|
||||
<el-row :gutter="16" class="stats-row">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-label">记录总数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card stat-pass">
|
||||
<div class="stat-label">已通过</div>
|
||||
<div class="stat-value">{{ stats.passed }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card stat-fail">
|
||||
<div class="stat-label">未通过</div>
|
||||
<div class="stat-value">{{ stats.failed }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card stat-doing">
|
||||
<div class="stat-label">考核中</div>
|
||||
<div class="stat-value">{{ stats.in_progress }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card stat-wait">
|
||||
<div class="stat-label">待审核</div>
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="never" class="stat-card stat-fee">
|
||||
<div class="stat-label">累计缴费(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(stats.total_fee) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 标签维度统计 -->
|
||||
<el-card v-if="stats.tag_stats?.length" shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">各标签考核统计</span>
|
||||
</template>
|
||||
<el-table :data="stats.tag_stats" size="small" stripe>
|
||||
<el-table-column prop="chenghao_name" label="考核标签" min-width="140" />
|
||||
<el-table-column prop="total" label="总数" width="80" align="center" />
|
||||
<el-table-column prop="passed" label="通过" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="text-success">{{ row.passed }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="failed" label="未通过" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="text-danger">{{ row.failed }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="in_progress" label="考核中" width="80" align="center" />
|
||||
<el-table-column prop="pending" label="待审核" width="80" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<el-card shadow="never" class="section-card filter-card">
|
||||
<el-form :inline="true" :model="filter" class="filter-form" @submit.prevent="handleSearch">
|
||||
<el-form-item label="记录ID">
|
||||
<el-input v-model="filter.jilu_id" placeholder="SH..." clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="打手">
|
||||
<el-input v-model="filter.dashou_keyword" placeholder="ID / 昵称 / 手机" clearable style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="考核官">
|
||||
<el-input v-model="filter.kaoheguan_keyword" placeholder="ID / 昵称 / 手机" clearable style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="板块">
|
||||
<el-select v-model="filter.bankuai_id" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="b in allBankuai" :key="b.bankuai_id" :label="b.mingcheng" :value="b.bankuai_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="filter.chenghao_id" placeholder="全部" clearable filterable style="width: 140px">
|
||||
<el-option v-for="t in allTags" :key="t.id" :label="t.mingcheng" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filter.zhuangtai" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option label="考核中" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="未通过" :value="2" />
|
||||
<el-option label="待审核" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="进度">
|
||||
<el-select v-model="filter.assessed" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已考核完" value="done" />
|
||||
<el-option label="未考核完" value="undone" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="第几次">
|
||||
<el-input v-model="filter.cishu" placeholder="次数" clearable style="width: 80px" />
|
||||
</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"
|
||||
style="width: 340px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-card shadow="never" class="section-card table-card">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="recordList"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
class="record-table"
|
||||
empty-text="暂无考核记录"
|
||||
>
|
||||
<el-table-column prop="jilu_id" label="记录ID" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="打手" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar :size="32" :src="avatarUrl(row.dashou?.avatar)" />
|
||||
<div class="user-meta">
|
||||
<div class="user-name">{{ row.dashou?.nicheng || '-' }}</div>
|
||||
<div class="user-sub">{{ row.dashou?.yonghuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考核官" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.kaoheguan" class="user-cell">
|
||||
<el-avatar :size="32" :src="avatarUrl(row.kaoheguan.avatar)" />
|
||||
<div class="user-meta">
|
||||
<div class="user-name">{{ row.kaoheguan.nicheng }}</div>
|
||||
<div class="user-sub">{{ row.kaoheguan.yonghuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag v-else type="info" size="small">未分配</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bankuai_name" label="板块" width="90" />
|
||||
<el-table-column prop="chenghao_name" label="考核标签" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="cishu" label="第几次" width="72" align="center" />
|
||||
<el-table-column label="本次费用" width="90" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.current_fee) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计缴费" width="90" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.jiaofei_jine) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.zhuangtai)" size="small">{{ row.zhuangtai_text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="CreateTime" label="申请时间" width="168" />
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.zhuangtai === 0"
|
||||
link
|
||||
type="warning"
|
||||
@click="handleToPending(row)"
|
||||
>退回待审核</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="考核记录详情" size="520px" destroy-on-close>
|
||||
<template v-if="detailData">
|
||||
<el-descriptions :column="1" border size="small" class="detail-block">
|
||||
<el-descriptions-item label="记录ID">{{ detailData.jilu_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detailData.zhuangtai)" size="small">{{ detailData.zhuangtai_text }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="板块">{{ detailData.bankuai_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="考核标签">{{ detailData.chenghao_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="第几次考核">第 {{ detailData.cishu }} 次</el-descriptions-item>
|
||||
<el-descriptions-item label="申请时间">{{ detailData.CreateTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ detailData.UpdateTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="block-title">打手信息</h4>
|
||||
<div class="profile-card">
|
||||
<el-avatar :size="48" :src="avatarUrl(detailData.dashou?.avatar)" />
|
||||
<div>
|
||||
<div>{{ detailData.dashou?.nicheng }}({{ detailData.dashou?.yonghuid }})</div>
|
||||
<div class="sub">手机:{{ detailData.dashou?.phone || '-' }} · 游戏ID:{{ detailData.dashou?.game_id || '-' }}</div>
|
||||
<div v-if="detailData.dashou?.beizhu" class="sub">备注:{{ detailData.dashou.beizhu }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="block-title">考核官</h4>
|
||||
<div v-if="detailData.kaoheguan" class="profile-card">
|
||||
<el-avatar :size="48" :src="avatarUrl(detailData.kaoheguan.avatar)" />
|
||||
<div>
|
||||
<div>{{ detailData.kaoheguan.nicheng }}({{ detailData.kaoheguan.yonghuid }})</div>
|
||||
<div class="sub">手机:{{ detailData.kaoheguan.phone || '-' }} · 游戏ID:{{ detailData.kaoheguan.game_id || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="尚未分配考核官" :image-size="60" />
|
||||
|
||||
<h4 v-if="detailData.guize_neirong" class="block-title">考核规则</h4>
|
||||
<div v-if="detailData.guize_neirong" class="rule-box">{{ detailData.guize_neirong }}</div>
|
||||
|
||||
<h4 class="block-title">缴费明细</h4>
|
||||
<el-table :data="detailData.fee_list || []" size="small" border empty-text="暂无明细">
|
||||
<el-table-column prop="cishu" label="第几次" width="80" align="center" />
|
||||
<el-table-column label="金额" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.jine) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<h4 class="block-title">拒绝历史</h4>
|
||||
<el-timeline v-if="detailData.reject_list?.length">
|
||||
<el-timeline-item
|
||||
v-for="(item, idx) in detailData.reject_list"
|
||||
:key="idx"
|
||||
:timestamp="item.CreateTime"
|
||||
placement="top"
|
||||
>
|
||||
<div>第 {{ item.cishu }} 次 · 考核官:{{ item.shenheguan_nicheng }}({{ item.shenheguan_id }})</div>
|
||||
<div class="reject-reason">{{ item.yuanyin }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="无拒绝记录" :image-size="60" />
|
||||
|
||||
<div v-if="detailData.zhuangtai === 0" class="drawer-actions">
|
||||
<el-button type="warning" @click="handleToPending(detailData, true)">退回待审核(取消指定考核官)</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
const hasPermission = ref(true)
|
||||
const loading = ref(false)
|
||||
const recordList = ref([])
|
||||
const allBankuai = ref([])
|
||||
const allTags = ref([])
|
||||
const dateRange = ref(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
in_progress: 0,
|
||||
pending: 0,
|
||||
total_fee: 0,
|
||||
tag_stats: [],
|
||||
})
|
||||
|
||||
const filter = reactive({
|
||||
jilu_id: '',
|
||||
dashou_keyword: '',
|
||||
kaoheguan_keyword: '',
|
||||
bankuai_id: null,
|
||||
chenghao_id: null,
|
||||
zhuangtai: null,
|
||||
assessed: null,
|
||||
cishu: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref(null)
|
||||
|
||||
const formatMoney = (val) => {
|
||||
if (val === undefined || val === null) return '0.00'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const avatarUrl = (path) => {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return (window.$ossURL || '') + path
|
||||
}
|
||||
|
||||
const statusTagType = (s) => {
|
||||
const map = { 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
phone: username,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (filter.jilu_id) params.jilu_id = filter.jilu_id
|
||||
if (filter.dashou_keyword) params.dashou_keyword = filter.dashou_keyword
|
||||
if (filter.kaoheguan_keyword) params.kaoheguan_keyword = filter.kaoheguan_keyword
|
||||
if (filter.bankuai_id != null && filter.bankuai_id !== '') params.bankuai_id = filter.bankuai_id
|
||||
if (filter.chenghao_id != null && filter.chenghao_id !== '') params.chenghao_id = filter.chenghao_id
|
||||
if (filter.zhuangtai != null && filter.zhuangtai !== '') params.zhuangtai = filter.zhuangtai
|
||||
if (filter.assessed) params.assessed = filter.assessed
|
||||
if (filter.cishu) params.cishu = filter.cishu
|
||||
if (dateRange.value?.length === 2) {
|
||||
params.start_time = dateRange.value[0]
|
||||
params.end_time = dateRange.value[1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/khjlgl', buildParams())
|
||||
if (res.code === 0) {
|
||||
const data = res.data || {}
|
||||
recordList.value = Array.isArray(data.list) ? data.list : []
|
||||
pagination.total = Number(data.total) || 0
|
||||
allBankuai.value = data.all_bankuai || []
|
||||
allTags.value = data.all_tags || []
|
||||
const s = data.stats || {}
|
||||
Object.assign(stats, {
|
||||
total: s.total || 0,
|
||||
passed: s.passed || 0,
|
||||
failed: s.failed || 0,
|
||||
in_progress: s.in_progress || 0,
|
||||
pending: s.pending || 0,
|
||||
total_fee: s.total_fee || 0,
|
||||
tag_stats: s.tag_stats || [],
|
||||
})
|
||||
if (pagination.total > 0 && recordList.value.length === 0) {
|
||||
ElMessage.warning('统计有数据但当前页列表为空,请检查后端是否已部署最新 khjlgl 接口')
|
||||
}
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('请求失败:请确认后端已部署 /houtai/khjlgl')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
filter.jilu_id = ''
|
||||
filter.dashou_keyword = ''
|
||||
filter.kaoheguan_keyword = ''
|
||||
filter.bankuai_id = null
|
||||
filter.chenghao_id = null
|
||||
filter.zhuangtai = null
|
||||
filter.assessed = null
|
||||
filter.cishu = ''
|
||||
dateRange.value = null
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
const res = await request.post('/houtai/khjlcz', {
|
||||
phone: username,
|
||||
action: 'get_detail',
|
||||
jilu_id: row.jilu_id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.data
|
||||
detailVisible.value = true
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取详情失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('网络错误')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToPending = async (row, fromDrawer = false) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'将把该记录从「考核中」退回「待审核」,并取消当前考核官指定。是否继续?',
|
||||
'确认操作',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
const res = await request.post('/houtai/khjlcz', {
|
||||
phone: username,
|
||||
action: 'to_pending',
|
||||
jilu_id: row.jilu_id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '操作成功')
|
||||
if (fromDrawer) detailVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!username) {
|
||||
ElMessage.error('未登录')
|
||||
return
|
||||
}
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
<style scoped>
|
||||
.exam-record-page {
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-pass .stat-value { color: #67c23a; }
|
||||
.stat-fail .stat-value { color: #f56c6c; }
|
||||
.stat-doing .stat-value { color: #e6a23c; }
|
||||
.stat-wait .stat-value { color: #409eff; }
|
||||
.stat-fee .stat-value { font-size: 18px; }
|
||||
|
||||
.section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
|
||||
/* 仅保证明细表格文字在 Layout 深色背景下可读,不改布局 */
|
||||
.record-table :deep(.el-table__body),
|
||||
.record-table :deep(.el-table__header) {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.record-table :deep(.el-table__header th) {
|
||||
color: #606266;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.user-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.text-success { color: #67c23a; }
|
||||
.text-danger { color: #f56c6c; }
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-permission {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 20px 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.profile-card .sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rule-box {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reject-reason {
|
||||
margin-top: 4px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,8 +21,19 @@
|
||||
<el-input v-model="filter.bankuai_name" placeholder="板块名称" clearable class="dark-input filter-input" />
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
<el-button type="success" :disabled="isAllClubScope" @click="openAddDialog">添加考核官</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="isAllClubScope"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="scope-alert"
|
||||
title="请切换到具体小程序视图后再添加考核官"
|
||||
description="添加考核官只能为本俱乐部用户操作,集团汇总视图下不可用。"
|
||||
/>
|
||||
|
||||
<!-- 板块标签统计(可折叠) -->
|
||||
<el-collapse v-model="activeCollapse" class="stat-collapse">
|
||||
<el-collapse-item title="板块标签统计" name="stats">
|
||||
@@ -211,6 +222,46 @@
|
||||
<el-button v-if="isEditMode" type="success" @click="saveExaminer" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加考核官 -->
|
||||
<el-dialog
|
||||
v-model="addVisible"
|
||||
title="添加考核官"
|
||||
width="520px"
|
||||
@close="closeAddDialog"
|
||||
custom-class="dark-dialog"
|
||||
>
|
||||
<el-form :model="addForm" label-width="110px">
|
||||
<el-form-item label="用户ID" required>
|
||||
<el-input v-model="addForm.yonghuid" placeholder="输入要打手用户ID" clearable class="dark-input" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证板块" required>
|
||||
<el-select
|
||||
v-model="addForm.bankuai_ids"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择板块(可多选)"
|
||||
class="dark-select"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="bk in allBankuaiList"
|
||||
:key="bk.bankuai_id"
|
||||
:label="bk.mingcheng"
|
||||
:value="bk.bankuai_id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="认证状态">
|
||||
<el-switch v-model="addForm.is_renzheng" active-text="已认证" inactive-text="未认证" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addSaving" @click="submitAddExaminer">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -219,10 +270,12 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { JITUAN_API, getAdminClubScope } from '@/utils/club-context'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
const hasPermission = ref(true)
|
||||
const loading = ref(false)
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
// 筛选条件
|
||||
const filter = reactive({
|
||||
@@ -248,6 +301,13 @@ const detailVisible = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const currentExaminer = ref({})
|
||||
const saving = ref(false)
|
||||
const addVisible = ref(false)
|
||||
const addSaving = ref(false)
|
||||
const addForm = reactive({
|
||||
yonghuid: '',
|
||||
bankuai_ids: [],
|
||||
is_renzheng: true,
|
||||
})
|
||||
|
||||
// 板块标签统计
|
||||
const bankuaiTagsData = ref([])
|
||||
@@ -286,7 +346,7 @@ const fetchExaminerList = async () => {
|
||||
page_size: pagination.pageSize,
|
||||
...filter
|
||||
}
|
||||
const res = await request.post('/houtai/khggl', params)
|
||||
const res = await request.post(JITUAN_API.khggl, params)
|
||||
if (res.code === 0) {
|
||||
const list = (res.data.list || []).map(item => ({
|
||||
...item,
|
||||
@@ -354,7 +414,7 @@ const addBankuai = async () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await request.post('/houtai/shgxgsj', {
|
||||
const res = await request.post(JITUAN_API.shgxgsj, {
|
||||
phone: username,
|
||||
yonghuid: currentExaminer.value.yonghuid,
|
||||
action: 'add_bankuai',
|
||||
@@ -382,7 +442,7 @@ const addBankuai = async () => {
|
||||
// 移除板块
|
||||
const removeBankuai = async (bankuaiId) => {
|
||||
try {
|
||||
const res = await request.post('/houtai/shgxgsj', {
|
||||
const res = await request.post(JITUAN_API.shgxgsj, {
|
||||
phone: username,
|
||||
yonghuid: currentExaminer.value.yonghuid,
|
||||
action: 'remove_bankuai',
|
||||
@@ -413,7 +473,7 @@ const saveExaminer = async () => {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/shgxgsj', payload)
|
||||
const res = await request.post(JITUAN_API.shgxgsj, payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('修改成功')
|
||||
isEditMode.value = false
|
||||
@@ -434,6 +494,53 @@ const saveExaminer = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
if (isAllClubScope.value) {
|
||||
ElMessage.warning('请切换到具体小程序视图后再添加考核官')
|
||||
return
|
||||
}
|
||||
addForm.yonghuid = ''
|
||||
addForm.bankuai_ids = []
|
||||
addForm.is_renzheng = true
|
||||
addVisible.value = true
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
addVisible.value = false
|
||||
}
|
||||
|
||||
const submitAddExaminer = async () => {
|
||||
const uid = (addForm.yonghuid || '').trim()
|
||||
if (!uid) {
|
||||
ElMessage.warning('请输入用户ID')
|
||||
return
|
||||
}
|
||||
if (!addForm.bankuai_ids.length) {
|
||||
ElMessage.warning('请选择至少一个板块')
|
||||
return
|
||||
}
|
||||
addSaving.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.khgglAdd, {
|
||||
phone: username,
|
||||
yonghuid: uid,
|
||||
bankuai_ids: addForm.bankuai_ids,
|
||||
is_renzheng: addForm.is_renzheng,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '添加成功')
|
||||
addVisible.value = false
|
||||
fetchExaminerList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '添加失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('网络错误')
|
||||
} finally {
|
||||
addSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!username) {
|
||||
ElMessage.error('未登录')
|
||||
@@ -474,6 +581,7 @@ onMounted(() => {
|
||||
}
|
||||
.filter-input { width: 160px; }
|
||||
.filter-select { width: 140px; }
|
||||
.scope-alert { margin-bottom: 16px; }
|
||||
|
||||
/* 板块标签统计面板 */
|
||||
.stat-collapse {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="agent-container">
|
||||
<!-- 客服连接组件(无界面,负责建连) -->
|
||||
<KefuConnect @connected="onConnected" />
|
||||
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="agent-header">
|
||||
<div class="header-left">
|
||||
<el-tag :type="isOnline ? 'success' : 'info'" size="large">
|
||||
{{ isOnline ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
<el-tag :type="statusTagType" size="large">{{ statusText }}</el-tag>
|
||||
<el-button
|
||||
v-if="goeasyReady"
|
||||
:type="isOnline ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleOnline"
|
||||
@@ -17,6 +13,7 @@
|
||||
>
|
||||
{{ isOnline ? '下线' : '上线' }}
|
||||
</el-button>
|
||||
<span class="online-tip" v-if="goeasyReady && isOnline">切换页面不会下线</span>
|
||||
<span class="unread-tip" v-if="unreadTotal">
|
||||
<i class="el-icon-chat-dot-round" style="color: red; margin-right: 4px;"></i>
|
||||
{{ unreadTotal }}条未读
|
||||
@@ -123,6 +120,7 @@
|
||||
:team-id="teamId"
|
||||
:customer-id="currentCustomerId"
|
||||
:customer-data="currentCustomerData"
|
||||
:agent-online="isOnline"
|
||||
@close="currentCustomerId = ''"
|
||||
/>
|
||||
</div>
|
||||
@@ -132,8 +130,18 @@
|
||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ChatDialog from '@/components/ChatDialog.vue'
|
||||
import KefuConnect from '@/components/KefuConnect.vue'
|
||||
import GoEasy from 'goeasy'
|
||||
import {
|
||||
KEFU_TEAM_ID,
|
||||
getCsteam,
|
||||
refreshKefuUnread,
|
||||
calcUnreadFromConversations,
|
||||
ensureCustomerAccepted,
|
||||
goOnlineManually,
|
||||
goOfflineManually,
|
||||
isGoeasyConnected,
|
||||
checkCsOnlineStatus
|
||||
} from '@/utils/kefuChat'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -145,13 +153,14 @@ const availableRoles = [
|
||||
{ prefix: 'Zz', name: '组长' }
|
||||
]
|
||||
|
||||
const isOnline = ref(false)
|
||||
const goeasyReady = ref(isGoeasyConnected())
|
||||
const isOnline = ref(!!window.__kefuCsOnline)
|
||||
const switching = ref(false)
|
||||
const tabActive = ref('pending')
|
||||
const pendingList = ref([])
|
||||
const activeList = ref([])
|
||||
const unreadTotal = ref(0)
|
||||
const teamId = 'support_team'
|
||||
const teamId = KEFU_TEAM_ID
|
||||
|
||||
const searchRole = ref('')
|
||||
const searchUid = ref('')
|
||||
@@ -164,12 +173,20 @@ const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726
|
||||
|
||||
const activeRole = ref('all')
|
||||
|
||||
function getCsteam() {
|
||||
if (!window.goeasy) return null
|
||||
return window.goeasy.im.csteam(teamId)
|
||||
const statusText = computed(() => {
|
||||
if (!goeasyReady.value) return '消息连接中…'
|
||||
return isOnline.value ? '在线接待中' : '已离线'
|
||||
})
|
||||
|
||||
const statusTagType = computed(() => {
|
||||
if (!goeasyReady.value) return 'info'
|
||||
return isOnline.value ? 'success' : 'warning'
|
||||
})
|
||||
|
||||
function getCsteamLocal() {
|
||||
return getCsteam()
|
||||
}
|
||||
|
||||
// 过滤
|
||||
function filterConversations(convs) {
|
||||
if (activeRole.value === 'all') return convs
|
||||
return convs.filter(c => {
|
||||
@@ -183,12 +200,10 @@ const applyFilter = () => {
|
||||
fetchActiveConversations()
|
||||
}
|
||||
|
||||
// 监听绑定(连接成功后立即执行)
|
||||
let conversationsUpdatedHandler = null
|
||||
let pendingUpdatedHandler = null
|
||||
|
||||
function bindConversationListeners() {
|
||||
// 先清旧
|
||||
if (conversationsUpdatedHandler) {
|
||||
window.goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
||||
}
|
||||
@@ -199,8 +214,8 @@ function bindConversationListeners() {
|
||||
conversationsUpdatedHandler = (data) => {
|
||||
const all = data.conversations || []
|
||||
activeList.value = filterConversations(all.filter(c => c.type === 'cs' && !c.ended))
|
||||
unreadTotal.value = data.unreadTotal || 0
|
||||
proxy.$emitter.emit('agent-unread', unreadTotal.value)
|
||||
unreadTotal.value = data.unreadTotal || calcUnreadFromConversations(activeList.value)
|
||||
refreshKefuUnread(proxy.$emitter)
|
||||
}
|
||||
window.goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
||||
|
||||
@@ -210,7 +225,6 @@ function bindConversationListeners() {
|
||||
window.goeasy.im.on(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
|
||||
}
|
||||
|
||||
// 连接成功回调
|
||||
const onConnected = () => {
|
||||
console.log('[Agent] 收到连接成功事件,立刻绑定会话监听')
|
||||
bindConversationListeners()
|
||||
@@ -218,63 +232,53 @@ const onConnected = () => {
|
||||
fetchActiveConversations()
|
||||
}
|
||||
|
||||
// 上线
|
||||
const csteamOnline = () => {
|
||||
if (!window.goeasy) {
|
||||
console.warn('[Agent] 未建立连接,无法上线')
|
||||
const toggleOnline = () => {
|
||||
if (switching.value || !goeasyReady.value) {
|
||||
ElMessage.warning('消息服务尚未连接,请稍候…')
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) return
|
||||
csteam.online({
|
||||
teamData: { name: '阿龙电竞客服中心', avatar: '' },
|
||||
agentData: {
|
||||
name: localStorage.getItem('username') || 'KF',
|
||||
avatar: ''
|
||||
},
|
||||
onSuccess: () => {
|
||||
isOnline.value = true
|
||||
console.log('[Agent] 上线成功')
|
||||
ElMessage.success('已上线,开始接收会话')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('[Agent] 上线失败:', error)
|
||||
ElMessage.error('上线失败:' + error.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下线
|
||||
const csteamOffline = () => {
|
||||
const csteam = getCsteam()
|
||||
if (!csteam) return
|
||||
csteam.offline({
|
||||
onSuccess: () => {
|
||||
isOnline.value = false
|
||||
console.log('[Agent] 下线成功')
|
||||
ElMessage.success('已下线')
|
||||
pendingList.value = []
|
||||
},
|
||||
onFailed: (error) => {
|
||||
ElMessage.error('下线失败:' + error.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOnline = () => {
|
||||
if (switching.value) return
|
||||
switching.value = true
|
||||
if (isOnline.value) {
|
||||
csteamOffline()
|
||||
} else {
|
||||
csteamOnline()
|
||||
const phone = localStorage.getItem('username')
|
||||
const stopSpin = () => { switching.value = false }
|
||||
const safetyTimer = setTimeout(stopSpin, 15000)
|
||||
|
||||
const wrapDone = (fn) => (...args) => {
|
||||
clearTimeout(safetyTimer)
|
||||
stopSpin()
|
||||
fn?.(...args)
|
||||
}
|
||||
|
||||
if (isOnline.value) {
|
||||
goOfflineManually({
|
||||
emitter: proxy.$emitter,
|
||||
onSuccess: wrapDone(() => {
|
||||
isOnline.value = false
|
||||
ElMessage.success('已下线,不再接收新会话')
|
||||
refreshKefuUnread(proxy.$emitter)
|
||||
}),
|
||||
onFailed: wrapDone((error) => {
|
||||
ElMessage.error('下线失败:' + (error?.content || '未知错误'))
|
||||
})
|
||||
})
|
||||
} else {
|
||||
goOnlineManually({
|
||||
phone,
|
||||
emitter: proxy.$emitter,
|
||||
onSuccess: wrapDone(() => {
|
||||
isOnline.value = true
|
||||
ElMessage.success('已上线,开始接收会话')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
refreshKefuUnread(proxy.$emitter)
|
||||
}),
|
||||
onFailed: wrapDone((error) => {
|
||||
const detail = error?.content || error?.message || JSON.stringify(error) || '未知错误'
|
||||
ElMessage.error('上线失败:' + detail)
|
||||
})
|
||||
})
|
||||
}
|
||||
setTimeout(() => { switching.value = false }, 1000)
|
||||
}
|
||||
|
||||
// 拉取待接入
|
||||
const fetchPendingConversations = () => {
|
||||
if (!window.goeasy) return
|
||||
window.goeasy.im.pendingConversations({
|
||||
@@ -284,7 +288,6 @@ const fetchPendingConversations = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 拉取已接入
|
||||
const fetchActiveConversations = () => {
|
||||
if (!window.goeasy) return
|
||||
window.goeasy.im.latestConversations({
|
||||
@@ -295,27 +298,43 @@ const fetchActiveConversations = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const acceptConversation = (conv) => {
|
||||
const acceptAndOpenChat = async (conv, openDialog = true) => {
|
||||
if (activeList.value.length >= 20) {
|
||||
ElMessage.warning('最多同时接入20个会话')
|
||||
return
|
||||
}
|
||||
const csteam = getCsteam()
|
||||
csteam.accept({
|
||||
customer: { id: conv.id, data: conv.data || { name: conv.id, avatar: defaultAvatar } },
|
||||
onSuccess: () => {
|
||||
ElMessage.success('已接入')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
},
|
||||
onFailed: (error) => {
|
||||
ElMessage.error('接入失败:' + error.content)
|
||||
if (!goeasyReady.value) {
|
||||
ElMessage.warning('消息服务连接中,请稍候…')
|
||||
return
|
||||
}
|
||||
if (!isOnline.value) {
|
||||
ElMessage.warning('请先上线后再接入会话')
|
||||
return
|
||||
}
|
||||
const customerId = conv.id || conv.userId
|
||||
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
|
||||
try {
|
||||
await ensureCustomerAccepted(customerId, customerData)
|
||||
ElMessage.success('已接入')
|
||||
fetchPendingConversations()
|
||||
fetchActiveConversations()
|
||||
if (openDialog) {
|
||||
currentCustomerId.value = customerId
|
||||
currentCustomerData.value = customerData
|
||||
dialogVisible.value = true
|
||||
tabActive.value = 'active'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
ElMessage.error('接入失败:' + (error?.content || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
const acceptConversation = (conv) => {
|
||||
acceptAndOpenChat(conv, true)
|
||||
}
|
||||
|
||||
const endConversation = (conv) => {
|
||||
const csteam = getCsteam()
|
||||
const csteam = getCsteamLocal()
|
||||
csteam.end({
|
||||
id: conv.id,
|
||||
onSuccess: () => {
|
||||
@@ -328,34 +347,109 @@ const endConversation = (conv) => {
|
||||
})
|
||||
}
|
||||
|
||||
const openChat = (conv) => {
|
||||
currentCustomerId.value = conv.id || conv.userId
|
||||
currentCustomerData.value = conv.data || { name: currentCustomerId.value, avatar: defaultAvatar }
|
||||
dialogVisible.value = true
|
||||
const openChat = async (conv) => {
|
||||
const customerId = conv.id || conv.userId
|
||||
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
|
||||
if (!goeasyReady.value) {
|
||||
ElMessage.warning('消息服务连接中,请稍候…')
|
||||
return
|
||||
}
|
||||
if (!isOnline.value) {
|
||||
ElMessage.warning('请先上线后再接入会话')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ensureCustomerAccepted(customerId, customerData)
|
||||
currentCustomerId.value = customerId
|
||||
currentCustomerData.value = customerData
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('接入会话失败:' + (error?.content || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
const startNewChat = () => {
|
||||
if (!searchRole.value || !searchUid.value) return
|
||||
const fullId = searchRole.value + searchUid.value
|
||||
const exists = activeList.value.find(c => c.id === fullId)
|
||||
if (exists) {
|
||||
openChat(exists)
|
||||
if (!goeasyReady.value) {
|
||||
ElMessage.warning('消息服务连接中,请稍候…')
|
||||
return
|
||||
}
|
||||
currentCustomerId.value = fullId
|
||||
currentCustomerData.value = { name: fullId, avatar: defaultAvatar }
|
||||
dialogVisible.value = true
|
||||
if (!isOnline.value) {
|
||||
ElMessage.warning('请先上线后再接入会话')
|
||||
return
|
||||
}
|
||||
|
||||
const uid = String(searchUid.value).trim()
|
||||
if (!/^\d+$/.test(uid)) {
|
||||
ElMessage.warning('UID 请输入纯数字')
|
||||
return
|
||||
}
|
||||
|
||||
const fullId = searchRole.value + uid
|
||||
const active = activeList.value.find(c => (c.id === fullId || c.userId === fullId))
|
||||
if (active) {
|
||||
openChat(active)
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingList.value.find(c => c.id === fullId)
|
||||
if (pending) {
|
||||
acceptAndOpenChat(pending, true)
|
||||
return
|
||||
}
|
||||
|
||||
acceptAndOpenChat({
|
||||
id: fullId,
|
||||
data: { name: fullId, avatar: defaultAvatar }
|
||||
}, true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[Agent] 页面挂载')
|
||||
const onAgentUnread = (count) => {
|
||||
unreadTotal.value = count
|
||||
}
|
||||
|
||||
const onGoeasyReady = (ready) => {
|
||||
goeasyReady.value = !!ready
|
||||
}
|
||||
|
||||
const onCsOnlineChange = (online) => {
|
||||
isOnline.value = !!online
|
||||
}
|
||||
|
||||
const onGoEasyConnected = () => {
|
||||
onConnected()
|
||||
goeasyReady.value = isGoeasyConnected()
|
||||
onCsOnlineChange(window.__kefuCsOnline)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
goeasyReady.value = isGoeasyConnected()
|
||||
if (goeasyReady.value) {
|
||||
const online = await checkCsOnlineStatus()
|
||||
isOnline.value = online
|
||||
proxy.$emitter.emit('kefu-cs-online', online)
|
||||
} else {
|
||||
isOnline.value = !!window.__kefuCsOnline
|
||||
}
|
||||
proxy.$emitter.on('kefu-goeasy-ready', onGoeasyReady)
|
||||
proxy.$emitter.on('kefu-cs-online', onCsOnlineChange)
|
||||
proxy.$emitter.on('agent-unread', onAgentUnread)
|
||||
if (window.goeasy && isGoeasyConnected()) {
|
||||
onConnected()
|
||||
} else {
|
||||
proxy.$emitter.on('goeasy-connected', onGoEasyConnected)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (conversationsUpdatedHandler) {
|
||||
proxy.$emitter.off('goeasy-connected', onGoEasyConnected)
|
||||
proxy.$emitter.off('kefu-goeasy-ready', onGoeasyReady)
|
||||
proxy.$emitter.off('kefu-cs-online', onCsOnlineChange)
|
||||
proxy.$emitter.off('agent-unread', onAgentUnread)
|
||||
if (window.goeasy && conversationsUpdatedHandler) {
|
||||
window.goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, conversationsUpdatedHandler)
|
||||
}
|
||||
if (pendingUpdatedHandler) {
|
||||
if (window.goeasy && pendingUpdatedHandler) {
|
||||
window.goeasy.im.off(GoEasy.IM_EVENT.PENDING_CONVERSATIONS_UPDATED, pendingUpdatedHandler)
|
||||
}
|
||||
})
|
||||
@@ -439,4 +533,8 @@ onUnmounted(() => {
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
.online-tip {
|
||||
font-size: 12px;
|
||||
color: #8bc34a;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAllClubScope" class="scope-block">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="板块为集团全局配置"
|
||||
description="请切换顶栏为「集团汇总(全部子公司)」后操作。具体小程序视图不可修改板块。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- ========== 顶部板块选择区(黑金导航) ========== -->
|
||||
<div class="section-header">
|
||||
<div class="bankuai-tabs">
|
||||
<span class="tabs-label">选择板块</span>
|
||||
@@ -287,9 +296,11 @@ import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue' // 新增 Edit、Delete 图标
|
||||
import request from '@/utils/request'
|
||||
import { getAdminClubScope } from '@/utils/club-context'
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
const bankuaiList = ref([])
|
||||
const allGoods = ref([])
|
||||
const allHuiyuans = ref([])
|
||||
@@ -577,11 +588,13 @@ onMounted(() => {
|
||||
ElMessage.error('未登录,请重新登录')
|
||||
return
|
||||
}
|
||||
if (!isAllClubScope.value) return
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-block { padding: 24px; }
|
||||
/* ==================== 黑金赛博朋克主题 ==================== */
|
||||
.bankuai-config {
|
||||
padding: 24px;
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
@@ -199,7 +200,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request.post('/houtai/cwqtczhq', params)
|
||||
const res = await request.post(JITUAN_API.chongzhiFinance, params)
|
||||
if (res.code === 0) {
|
||||
timeSeries.value = res.data.time_series || []
|
||||
Object.assign(summary, res.data.summary || {})
|
||||
|
||||
@@ -72,10 +72,12 @@
|
||||
<div class="summary-item">
|
||||
<span class="s-label">总收入</span>
|
||||
<span class="s-value income">¥{{ formatMoney(summary.total_income) }}</span>
|
||||
<span class="s-sub">{{ summary.total_income_count || 0 }} 笔</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="s-label">总支出</span>
|
||||
<span class="s-value payout">¥{{ formatMoney(summary.total_payout) }}</span>
|
||||
<span class="s-sub">{{ summary.total_payout_count || 0 }} 笔</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="s-label">总利润</span>
|
||||
@@ -91,17 +93,26 @@
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
const hasPermission = ref(true)
|
||||
|
||||
/** 本地日期 YYYY-MM-DD(不用 toISOString,避免 UTC 差一天) */
|
||||
const localDateStr = (d = new Date()) => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const granularity = ref('day')
|
||||
const year = ref(new Date().getFullYear())
|
||||
const month = ref(new Date().getMonth() + 1)
|
||||
const summaryDate = ref(new Date().toISOString().slice(0, 10))
|
||||
const summaryMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
const summaryDate = ref(localDateStr())
|
||||
const summaryMonth = ref(localDateStr().slice(0, 7))
|
||||
const summaryYear = ref(new Date().getFullYear())
|
||||
|
||||
// 数据
|
||||
@@ -132,9 +143,9 @@ const formatMoney = (val) => {
|
||||
// 颗粒度切换重置默认值
|
||||
const onGranularityChange = () => {
|
||||
if (granularity.value === 'day') {
|
||||
summaryDate.value = new Date().toISOString().slice(0, 10)
|
||||
summaryDate.value = localDateStr()
|
||||
} else if (granularity.value === 'month') {
|
||||
summaryMonth.value = new Date().toISOString().slice(0, 7)
|
||||
summaryMonth.value = localDateStr().slice(0, 7)
|
||||
} else {
|
||||
summaryYear.value = new Date().getFullYear()
|
||||
}
|
||||
@@ -158,7 +169,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request.post('/houtai/szxx', params)
|
||||
const res = await request.post(JITUAN_API.szxx, params)
|
||||
if (res.code === 0) {
|
||||
timeSeries.value = res.data.time_series || []
|
||||
Object.assign(summary, res.data.summary || {})
|
||||
|
||||
@@ -41,10 +41,18 @@
|
||||
<span class="split">/</span>
|
||||
<span class="out">¥{{ formatMoney(data.total_payout) }}</span>
|
||||
</div>
|
||||
<div class="kpi-sub">累计:日统计表全部天数收入合计 / 支出合计</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 原账户总收入/总支出卡片保留在第二行指标中通过 daily_stats 体现;club_id 提示 -->
|
||||
<el-row v-if="clubHint" :gutter="16" class="kpi-row club-hint-row">
|
||||
<el-col :span="24">
|
||||
<div class="club-hint">{{ clubHint }}</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 第二行:订单与会员充值概况(3列) -->
|
||||
<el-row :gutter="16" class="kpi-row">
|
||||
<el-col :span="8">
|
||||
@@ -151,6 +159,8 @@
|
||||
import { ref, reactive, computed, onMounted, markRaw } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import { getAdminClubId, getAdminClubScope } from '@/utils/club-context'
|
||||
import DailyIncomeDetail from './DailyIncomeDetail.vue'
|
||||
import OrderDataDetail from './OrderDataDetail.vue'
|
||||
import HuiyuanDataDetail from './HuiyuanDataDetail.vue'
|
||||
@@ -184,6 +194,15 @@ const data = reactive({
|
||||
daily_stats: []
|
||||
})
|
||||
|
||||
const clubHint = computed(() => {
|
||||
const scope = getAdminClubScope()
|
||||
const cid = getAdminClubId()
|
||||
if (scope === 'all') {
|
||||
return '当前:集团汇总(全平台合计)'
|
||||
}
|
||||
return `当前:俱乐部 ${cid}(明细按俱乐部筛选)`
|
||||
})
|
||||
|
||||
const activeTab = ref('daily_income')
|
||||
const detailTabs = [
|
||||
{ key: 'daily_income', label: '每日收支详细' },
|
||||
@@ -211,7 +230,7 @@ const formatMoney = (val) => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/caiwu', { phone: username.value })
|
||||
const res = await request.post(JITUAN_API.caiwu, { phone: username.value })
|
||||
if (res.code === 0) {
|
||||
Object.assign(data, res.data)
|
||||
} else if (res.code === 403) {
|
||||
@@ -439,4 +458,9 @@ onMounted(() => {
|
||||
.detail-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
.club-hint {
|
||||
font-size: 13px;
|
||||
color: #9aa0b5;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -130,6 +130,7 @@
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
@@ -186,7 +187,7 @@ const formatMoney = (val) => {
|
||||
// 获取板块及会员
|
||||
const fetchBankuaiList = async () => {
|
||||
try {
|
||||
const res = await request.post('/houtai/cwhybkhq', { phone: username })
|
||||
const res = await request.post(JITUAN_API.huiyuanBankuai, { phone: username })
|
||||
if (res.code === 0) {
|
||||
bankuaiList.value = res.data
|
||||
if (bankuaiList.value.length > 0) {
|
||||
@@ -254,7 +255,7 @@ const fetchData = async () => {
|
||||
params.summary_date = summaryYear.value.toString()
|
||||
}
|
||||
try {
|
||||
const res = await request.post('/houtai/hybkjtsj', params)
|
||||
const res = await request.post(JITUAN_API.huiyuanStats, params)
|
||||
if (res.code === 0) {
|
||||
timeSeries.value = res.data.time_series || []
|
||||
summary.value = res.data.summary || {
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
@@ -156,7 +157,7 @@ const formatMoney = (val) => {
|
||||
// 获取订单类型列表
|
||||
const fetchOrderTypes = async () => {
|
||||
try {
|
||||
const res = await request.post('/houtai/cwddhqlx', { phone: username })
|
||||
const res = await request.post(JITUAN_API.orderTypes, { phone: username })
|
||||
if (res.code === 0) {
|
||||
orderTypes.value = res.data || []
|
||||
} else if (res.code === 403) {
|
||||
@@ -197,7 +198,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request.post('/houtai/cwhqjtddsj', params)
|
||||
const res = await request.post(JITUAN_API.orderFinance, params)
|
||||
if (res.code === 0) {
|
||||
timeSeries.value = res.data.time_series || []
|
||||
Object.assign(summary, res.data.summary || {})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="member-manager">
|
||||
<!-- 权限不足锁屏 -->
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</div>
|
||||
@@ -8,17 +7,58 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 顶部操作栏 -->
|
||||
<ClubScopeHint />
|
||||
<el-alert
|
||||
v-if="priceNote"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="member-price-tip"
|
||||
:title="`俱乐部 ${clubId} 会员价`"
|
||||
:description="priceNote"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="isAllClubScope"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="member-scope-tip"
|
||||
title="集团汇总视图"
|
||||
description="可新建全局会员类型;修改售价/体验配置请切换到具体俱乐部。"
|
||||
/>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="openAddDialog" :icon="Plus">添加会员</el-button>
|
||||
<el-button
|
||||
v-if="isAllClubScope"
|
||||
type="primary"
|
||||
@click="openAddDialog"
|
||||
:icon="Plus"
|
||||
>新建全局会员类型</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="openCatalogDialog"
|
||||
:icon="Plus"
|
||||
>上架会员</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 会员卡片网格 -->
|
||||
<div class="member-grid" v-loading="loading">
|
||||
<div v-for="member in memberList" :key="member.huiyuan_id" class="member-card">
|
||||
<div class="card-header">
|
||||
<div class="member-name">{{ member.jieshao }}</div>
|
||||
<div class="member-price">¥{{ formatMoney(member.jiage) }}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="member-price">¥{{ formatMoney(member.jiage) }}<span class="days-hint">/{{ member.formal_days || 30 }}天</span></div>
|
||||
<div
|
||||
v-if="member.price_source === 'club' && member.global_jiage !== member.jiage"
|
||||
class="global-price-hint"
|
||||
>
|
||||
全局默认 ¥{{ formatMoney(member.global_jiage) }}
|
||||
</div>
|
||||
<div v-if="member.trial_enabled" class="trial-price-hint">
|
||||
体验 ¥{{ formatMoney(member.trial_price) }}/{{ member.trial_days }}天
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<div class="stat-item">
|
||||
@@ -36,55 +76,80 @@
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="openDetailDialog(member)">详情</el-button>
|
||||
<el-button
|
||||
v-if="!isAllClubScope"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmDisable(member)"
|
||||
>下架</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loading && memberList.length === 0" class="empty-state">
|
||||
<el-empty description="暂无会员数据" />
|
||||
<el-empty :description="isAllClubScope ? '暂无全局会员类型' : '本俱乐部尚未上架会员,请点击「上架会员」'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会员详情弹窗(展示+编辑) -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="isEditMode ? '编辑会员' : '会员详情'"
|
||||
width="600px"
|
||||
width="640px"
|
||||
@close="closeDetailDialog"
|
||||
>
|
||||
<el-form :model="currentMember" :rules="formRules" ref="detailFormRef" label-width="120px">
|
||||
<el-form :model="currentMember" :rules="formRules" ref="detailFormRef" label-width="130px">
|
||||
<el-form-item label="会员名称" prop="jieshao">
|
||||
<el-input v-model="currentMember.jieshao" :disabled="!isEditMode" maxlength="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会员价格(元)" prop="jiage">
|
||||
<el-form-item label="正式价格(元)" prop="jiage">
|
||||
<el-input-number v-model="currentMember.jiage" :min="0.01" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正式天数" prop="formal_days">
|
||||
<el-input-number v-model="currentMember.formal_days" :min="1" :max="3650" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="管事分成(元)" prop="guanshifc">
|
||||
<el-input-number v-model="currentMember.guanshifc" :min="0" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<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-switch v-model="currentMember.trial_enabled" :disabled="!isEditMode" />
|
||||
</el-form-item>
|
||||
<template v-if="currentMember.trial_enabled">
|
||||
<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>
|
||||
<el-form-item label="体验天数" prop="trial_days">
|
||||
<el-input-number v-model="currentMember.trial_days" :min="1" :max="3650" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体验管事分成(元)" prop="trial_guanshifc">
|
||||
<el-input-number v-model="currentMember.trial_guanshifc" :min="0" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体验组长分成(元)" prop="trial_zuzhangfc">
|
||||
<el-input-number v-model="currentMember.trial_zuzhangfc" :min="0" :precision="2" :step="1" :disabled="!isEditMode" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="详细规则介绍" prop="jtjieshao">
|
||||
<el-input v-model="currentMember.jtjieshao" type="textarea" rows="4" :disabled="!isEditMode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购买次数" v-if="!isEditMode">
|
||||
<el-input :value="currentMember.goumai_cishu || 0" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" v-if="!isEditMode && currentMember.create_time">
|
||||
<el-input :value="formatDate(currentMember.create_time)" disabled />
|
||||
<el-form-item label="创建时间" v-if="!isEditMode && currentMember.CreateTime">
|
||||
<el-input :value="formatDate(currentMember.CreateTime)" disabled />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">取消</el-button>
|
||||
<el-button v-if="isEditMode" type="primary" @click="saveMember" :loading="saving">保存修改</el-button>
|
||||
<el-button v-else type="primary" @click="enableEdit">编辑</el-button>
|
||||
<el-button v-else-if="!isAllClubScope" type="primary" @click="enableEdit">编辑</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加会员弹窗 -->
|
||||
<el-dialog
|
||||
v-model="addDialogVisible"
|
||||
title="添加会员"
|
||||
title="新建全局会员类型"
|
||||
width="600px"
|
||||
@close="closeAddDialog"
|
||||
>
|
||||
@@ -102,37 +167,114 @@
|
||||
<el-input-number v-model="addForm.zuzhangfc" :min="0" :precision="2" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="详细规则介绍" prop="jtjieshao">
|
||||
<el-input v-model="addForm.jtjieshao" type="textarea" rows="4" />
|
||||
<el-input v-model="addForm.jtjieshao" type="textarea" rows="4" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAdd" :loading="adding">确认添加</el-button>
|
||||
<el-button type="primary" @click="submitAdd" :loading="adding">确认创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="catalogDialogVisible"
|
||||
title="从集团选用会员上架"
|
||||
width="520px"
|
||||
@close="closeCatalogDialog"
|
||||
>
|
||||
<div v-loading="catalogLoading">
|
||||
<el-empty v-if="!catalogLoading && catalogList.length === 0" description="暂无可上架的会员类型" />
|
||||
<div v-else class="catalog-list">
|
||||
<div
|
||||
v-for="item in catalogList"
|
||||
:key="item.huiyuan_id"
|
||||
class="catalog-item"
|
||||
@click="openEnableDialog(item)"
|
||||
>
|
||||
<div class="catalog-name">{{ item.jieshao }}</div>
|
||||
<div class="catalog-id">ID: {{ item.huiyuan_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="enableDialogVisible"
|
||||
title="上架会员配置"
|
||||
width="640px"
|
||||
@close="closeEnableDialog"
|
||||
>
|
||||
<el-alert
|
||||
v-if="enableTarget.jieshao"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="enable-tip"
|
||||
:title="enableTarget.jieshao"
|
||||
:description="`会员 ID:${enableTarget.huiyuan_id}`"
|
||||
/>
|
||||
<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%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正式天数" prop="formal_days">
|
||||
<el-input-number v-model="enableForm.formal_days" :min="1" :max="3650" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="管事分成(元)" prop="guanshifc">
|
||||
<el-input-number v-model="enableForm.guanshifc" :min="0" :precision="2" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<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-switch v-model="enableForm.trial_enabled" />
|
||||
</el-form-item>
|
||||
<template v-if="enableForm.trial_enabled">
|
||||
<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>
|
||||
<el-form-item label="体验天数" prop="trial_days">
|
||||
<el-input-number v-model="enableForm.trial_days" :min="1" :max="3650" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体验管事分成(元)" prop="trial_guanshifc">
|
||||
<el-input-number v-model="enableForm.trial_guanshifc" :min="0" :precision="2" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体验组长分成(元)" prop="trial_zuzhangfc">
|
||||
<el-input-number v-model="enableForm.trial_zuzhangfc" :min="0" :precision="2" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="enableDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitEnable" :loading="enabling">确认上架</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { cloneDeep, isEqual } from 'lodash-es'
|
||||
import { JITUAN_API, getAdminClubScope } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const loading = ref(false)
|
||||
const memberList = ref([])
|
||||
const priceNote = ref('')
|
||||
const clubId = ref('xq')
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
// 详情弹窗相关
|
||||
const detailDialogVisible = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const currentMember = ref({})
|
||||
const detailFormRef = ref(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// 添加弹窗相关
|
||||
const addDialogVisible = ref(false)
|
||||
const addFormRef = ref(null)
|
||||
const adding = ref(false)
|
||||
@@ -144,49 +286,122 @@ const addForm = ref({
|
||||
jtjieshao: ''
|
||||
})
|
||||
|
||||
// 表单校验规则(共用)
|
||||
const catalogDialogVisible = ref(false)
|
||||
const catalogLoading = ref(false)
|
||||
const catalogList = ref([])
|
||||
|
||||
const enableDialogVisible = ref(false)
|
||||
const enableFormRef = ref(null)
|
||||
const enabling = ref(false)
|
||||
const enableTarget = ref({})
|
||||
const enableForm = ref(defaultClubPriceForm())
|
||||
|
||||
function defaultClubPriceForm() {
|
||||
return {
|
||||
jiage: 0,
|
||||
formal_days: 30,
|
||||
guanshifc: 0,
|
||||
zuzhangfc: 0,
|
||||
trial_enabled: false,
|
||||
trial_price: 0,
|
||||
trial_days: 7,
|
||||
trial_guanshifc: 0,
|
||||
trial_zuzhangfc: 0
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMember(member) {
|
||||
return {
|
||||
...member,
|
||||
jiage: Number(member.jiage || 0),
|
||||
guanshifc: Number(member.guanshifc || 0),
|
||||
zuzhangfc: Number(member.zuzhangfc || 0),
|
||||
formal_days: Number(member.formal_days || 30),
|
||||
trial_enabled: Boolean(member.trial_enabled),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function buildTrialPayload(form) {
|
||||
return {
|
||||
formal_days: form.formal_days,
|
||||
trial_enabled: form.trial_enabled,
|
||||
trial_price: form.trial_price,
|
||||
trial_days: form.trial_days,
|
||||
trial_guanshifc: form.trial_guanshifc,
|
||||
trial_zuzhangfc: form.trial_zuzhangfc
|
||||
}
|
||||
}
|
||||
|
||||
function validatePriceSplit(jiage, guanshifc, zuzhangfc, trialEnabled, trialPrice, trialGuanshi, trialZuzhang) {
|
||||
if (guanshifc + zuzhangfc > jiage) {
|
||||
ElMessage.error('管事分成+组长分成不能大于正式会员价格')
|
||||
return false
|
||||
}
|
||||
if (trialEnabled) {
|
||||
if (trialGuanshi + trialZuzhang > trialPrice) {
|
||||
ElMessage.error('体验管事+组长分成不能大于体验价格')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
jieshao: [{ required: true, message: '请输入会员名称', trigger: 'blur' }],
|
||||
jiage: [{ required: true, message: '请输入会员价格', trigger: 'blur' }],
|
||||
guanshifc: [{ required: true, message: '请输入管事分成', trigger: 'blur' }],
|
||||
zuzhangfc: [{ required: true, message: '请输入组长分成', trigger: 'blur' }],
|
||||
jtjieshao: [{ required: true, message: '请输入详细规则介绍', trigger: 'blur' }]
|
||||
zuzhangfc: [{ required: true, message: '请输入组长分成', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const addRules = {
|
||||
...formRules,
|
||||
jieshao: formRules.jieshao,
|
||||
jiage: [
|
||||
{ required: true, message: '请输入会员价格', trigger: 'blur' },
|
||||
{ validator: (rule, value, callback) => {
|
||||
const total = addForm.value.guanshifc + addForm.value.zuzhangfc
|
||||
if (value < total) {
|
||||
callback(new Error('会员价格必须大于或等于管事分成+组长分成'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
const total = addForm.value.guanshifc + addForm.value.zuzhangfc
|
||||
if (value < total) {
|
||||
callback(new Error('会员价格必须大于或等于管事分成+组长分成'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
guanshifc: [{ required: true, message: '请输入管事分成', trigger: 'blur' }],
|
||||
zuzhangfc: [{ required: true, message: '请输入组长分成', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const enableRules = {
|
||||
jiage: [{ required: true, message: '请输入正式价格', trigger: 'blur' }],
|
||||
guanshifc: [{ required: true, message: '请输入管事分成', trigger: 'blur' }],
|
||||
zuzhangfc: [{ required: true, message: '请输入组长分成', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatMoney = (val) => {
|
||||
if (val === undefined || val === null) return '0.00'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 获取会员列表
|
||||
const fetchMemberList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/hthqhylb', { username: username.value })
|
||||
const res = await request.post(JITUAN_API.memberList, { username: username.value })
|
||||
if (res.code === 0) {
|
||||
memberList.value = res.data.list || []
|
||||
memberList.value = (res.data.list || []).map(normalizeMember)
|
||||
priceNote.value = res.price_note || res.data?.price_note || ''
|
||||
clubId.value = res.club_id || res.data?.club_id || 'xq'
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
@@ -200,33 +415,24 @@ const fetchMemberList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
const openDetailDialog = (member) => {
|
||||
currentMember.value = cloneDeep(member)
|
||||
currentMember.value = normalizeMember(cloneDeep(member))
|
||||
isEditMode.value = false
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
// 启用编辑模式
|
||||
|
||||
const enableEdit = () => {
|
||||
isEditMode.value = true
|
||||
}
|
||||
// 保存修改
|
||||
|
||||
const saveMember = async () => {
|
||||
try {
|
||||
await detailFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 检查是否有改动(对比原始数据)
|
||||
const original = memberList.value.find(m => m.huiyuan_id === currentMember.value.huiyuan_id)
|
||||
if (original && isEqual(original, currentMember.value)) {
|
||||
ElMessage.info('未检测到任何修改')
|
||||
return
|
||||
}
|
||||
// 校验分成之和不超过价格
|
||||
const total = currentMember.value.guanshifc + currentMember.value.zuzhangfc
|
||||
if (total > currentMember.value.jiage) {
|
||||
ElMessage.error('管事分成+组长分成不能大于会员价格')
|
||||
const m = currentMember.value
|
||||
if (!validatePriceSplit(m.jiage, m.guanshifc, m.zuzhangfc, m.trial_enabled, m.trial_price, m.trial_guanshifc, m.trial_zuzhangfc)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -240,24 +446,21 @@ const saveMember = async () => {
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/htxghyxx', {
|
||||
const res = await request.post(JITUAN_API.memberUpdate, {
|
||||
username: username.value,
|
||||
huiyuan_id: currentMember.value.huiyuan_id,
|
||||
jieshao: currentMember.value.jieshao,
|
||||
jiage: currentMember.value.jiage,
|
||||
guanshifc: currentMember.value.guanshifc,
|
||||
zuzhangfc: currentMember.value.zuzhangfc,
|
||||
jtjieshao: currentMember.value.jtjieshao || ''
|
||||
huiyuan_id: m.huiyuan_id,
|
||||
jieshao: m.jieshao,
|
||||
jiage: m.jiage,
|
||||
guanshifc: m.guanshifc,
|
||||
zuzhangfc: m.zuzhangfc,
|
||||
jtjieshao: m.jtjieshao || '',
|
||||
...buildTrialPayload(m)
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('修改成功')
|
||||
// 更新本地列表
|
||||
const index = memberList.value.findIndex(m => m.huiyuan_id === currentMember.value.huiyuan_id)
|
||||
if (index !== -1) {
|
||||
memberList.value[index] = { ...currentMember.value }
|
||||
}
|
||||
detailDialogVisible.value = false
|
||||
isEditMode.value = false
|
||||
await fetchMemberList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '修改失败')
|
||||
}
|
||||
@@ -268,14 +471,18 @@ const saveMember = async () => {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeDetailDialog = () => {
|
||||
detailDialogVisible.value = false
|
||||
isEditMode.value = false
|
||||
currentMember.value = {}
|
||||
}
|
||||
|
||||
// 添加会员
|
||||
const openAddDialog = () => {
|
||||
if (!isAllClubScope.value) {
|
||||
ElMessage.warning('俱乐部请使用「上架会员」从集团 catalog 选用')
|
||||
return
|
||||
}
|
||||
addForm.value = {
|
||||
jieshao: '',
|
||||
jiage: 0,
|
||||
@@ -285,16 +492,26 @@ const openAddDialog = () => {
|
||||
}
|
||||
addDialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
addDialogVisible.value = false
|
||||
addFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const submitAdd = async () => {
|
||||
if (!isAllClubScope.value) {
|
||||
ElMessage.warning('仅集团汇总视图可新建全局会员类型')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (addForm.value.jiage <= 0) {
|
||||
ElMessage.error('会员价格必须大于 0')
|
||||
return
|
||||
}
|
||||
const total = addForm.value.guanshifc + addForm.value.zuzhangfc
|
||||
if (total > addForm.value.jiage) {
|
||||
ElMessage.error('管事分成+组长分成不能大于会员价格')
|
||||
@@ -302,7 +519,7 @@ const submitAdd = async () => {
|
||||
}
|
||||
adding.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/httjhy', {
|
||||
const res = await request.post(JITUAN_API.memberAdd, {
|
||||
username: username.value,
|
||||
jieshao: addForm.value.jieshao,
|
||||
jiage: addForm.value.jiage,
|
||||
@@ -311,24 +528,130 @@ const submitAdd = async () => {
|
||||
jtjieshao: addForm.value.jtjieshao || ''
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('添加成功')
|
||||
ElMessage.success('创建成功')
|
||||
addDialogVisible.value = false
|
||||
await fetchMemberList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '添加失败')
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('添加失败')
|
||||
ElMessage.error('创建失败')
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCatalogDialog = async () => {
|
||||
if (isAllClubScope.value) {
|
||||
ElMessage.warning('请切换到具体俱乐部后再上架会员')
|
||||
return
|
||||
}
|
||||
catalogDialogVisible.value = true
|
||||
catalogLoading.value = true
|
||||
catalogList.value = []
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.memberCatalog, { username: username.value })
|
||||
if (res.code === 0) {
|
||||
catalogList.value = res.data?.catalog || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取可上架列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('获取可上架列表失败')
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeCatalogDialog = () => {
|
||||
catalogDialogVisible.value = false
|
||||
catalogList.value = []
|
||||
}
|
||||
|
||||
const openEnableDialog = (item) => {
|
||||
enableTarget.value = item
|
||||
enableForm.value = defaultClubPriceForm()
|
||||
enableDialogVisible.value = true
|
||||
catalogDialogVisible.value = false
|
||||
}
|
||||
|
||||
const closeEnableDialog = () => {
|
||||
enableDialogVisible.value = false
|
||||
enableTarget.value = {}
|
||||
enableFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const submitEnable = async () => {
|
||||
try {
|
||||
await enableFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const f = enableForm.value
|
||||
if (f.jiage <= 0) {
|
||||
ElMessage.error('正式价格必须大于 0')
|
||||
return
|
||||
}
|
||||
if (!validatePriceSplit(f.jiage, f.guanshifc, f.zuzhangfc, f.trial_enabled, f.trial_price, f.trial_guanshifc, f.trial_zuzhangfc)) {
|
||||
return
|
||||
}
|
||||
enabling.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.memberEnable, {
|
||||
username: username.value,
|
||||
huiyuan_id: enableTarget.value.huiyuan_id,
|
||||
jiage: f.jiage,
|
||||
guanshifc: f.guanshifc,
|
||||
zuzhangfc: f.zuzhangfc,
|
||||
...buildTrialPayload(f)
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上架成功')
|
||||
enableDialogVisible.value = false
|
||||
await fetchMemberList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上架失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('上架失败')
|
||||
} finally {
|
||||
enabling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDisable = async (member) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将「${member.jieshao}」在本俱乐部下架吗?下架后小程序将不再展示该会员。`,
|
||||
'确认下架',
|
||||
{ confirmButtonText: '下架', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.memberDisable, {
|
||||
username: username.value,
|
||||
huiyuan_id: member.huiyuan_id
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已下架')
|
||||
await fetchMemberList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '下架失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('下架失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!username.value) {
|
||||
ElMessage.error('未登录,请重新登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
fetchMemberList()
|
||||
@@ -366,23 +689,45 @@ onMounted(() => {
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #2a6f8f;
|
||||
}
|
||||
.member-name-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.member-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #6cc3e8;
|
||||
}
|
||||
.trial-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.member-price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f0a0a0;
|
||||
}
|
||||
.days-hint {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #8ab3cf;
|
||||
}
|
||||
.global-price-hint,
|
||||
.trial-price-hint {
|
||||
font-size: 12px;
|
||||
color: #8ab3cf;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.member-price-tip,
|
||||
.member-scope-tip,
|
||||
.enable-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -402,12 +747,38 @@ onMounted(() => {
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.catalog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.catalog-item {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #2a6f8f;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.catalog-item:hover {
|
||||
border-color: #6cc3e8;
|
||||
}
|
||||
.catalog-name {
|
||||
font-size: 16px;
|
||||
color: #6cc3e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
.catalog-id {
|
||||
font-size: 12px;
|
||||
color: #8ab3cf;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.no-permission {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
@@ -424,7 +795,6 @@ onMounted(() => {
|
||||
color: #8ab3cf;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 表单组件深色样式(与商品详情页保持一致) */
|
||||
:deep(.el-form-item__label) {
|
||||
color: #d4f1ff !important;
|
||||
}
|
||||
@@ -455,4 +825,4 @@ onMounted(() => {
|
||||
:deep(.el-dialog__headerbtn .el-dialog__close) {
|
||||
color: #8ab3cf;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
430
src/views/miniapp/CarouselManage.vue
Normal file
430
src/views/miniapp/CarouselManage.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="carousel-manage">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</div>
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ClubScopeHint />
|
||||
<p v-if="scopeHint" class="scope-extra-hint">{{ scopeHint }}</p>
|
||||
|
||||
<div class="header-bar">
|
||||
<h2>轮播图与公告管理</h2>
|
||||
<el-select v-model="pageKey" placeholder="选择页面" @change="onPageKeyChange">
|
||||
<el-option v-for="opt in pageOptions" :key="opt.key" :label="opt.label" :value="opt.key" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<template #header><strong>{{ gonggaoTitle }}</strong></template>
|
||||
<el-input
|
||||
v-model="gonggaoContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="300"
|
||||
show-word-limit
|
||||
placeholder="请输入公告内容(最多300字)"
|
||||
/>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" :loading="gonggaoSaving" @click="saveGonggao">保存公告</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="lunbo-header">
|
||||
<strong>轮播图片</strong>
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleUpload"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
>
|
||||
<el-button type="primary" size="small" :loading="uploading">上传图片</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="lunboList.length" class="lunbo-grid">
|
||||
<div v-for="url in lunboList" :key="url" class="lunbo-item">
|
||||
<el-image :src="getFullUrl(url)" fit="cover" class="lunbo-preview" />
|
||||
<el-button type="danger" link @click="deleteLunbo(url)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">暂无轮播图</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<template #header><strong>小程序杂项图片(按俱乐部)</strong></template>
|
||||
<p class="misc-hint">默认头像、打手规则、关注快手页图片存于 tupianpeizhi 表,各俱乐部独立。</p>
|
||||
<div v-for="(block, key) in tupianTypes" :key="key" class="tupian-block">
|
||||
<div class="tupian-block-head">
|
||||
<strong>{{ block.label }}</strong>
|
||||
<el-upload
|
||||
v-if="block.multi || !block.list.length"
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(f) => handleTupianUpload(f, Number(key))"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
>
|
||||
<el-button size="small" type="primary" :loading="tupianUploading === Number(key)">上传</el-button>
|
||||
</el-upload>
|
||||
<el-upload
|
||||
v-else
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(f) => handleTupianUpload(f, Number(key))"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
>
|
||||
<el-button size="small" :loading="tupianUploading === Number(key)">替换</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div v-if="block.list.length" class="lunbo-grid">
|
||||
<div v-for="item in block.list" :key="item.id || item.image_url" class="lunbo-item">
|
||||
<el-image :src="getFullUrl(item.image_url)" fit="cover" class="lunbo-preview" />
|
||||
<el-button type="danger" link @click="deleteTupian(Number(key), item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip small">未配置</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
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 gonggaoTitle = computed(() => {
|
||||
const opt = pageOptions.value.find((o) => o.key === pageKey.value)
|
||||
const label = opt ? opt.label : pageKey.value
|
||||
return `${label} · 页面公告`
|
||||
})
|
||||
const lunboList = ref([])
|
||||
const gonggaoContent = ref('')
|
||||
const scopeHint = ref('')
|
||||
const uploading = ref(false)
|
||||
const gonggaoSaving = ref(false)
|
||||
const ossBaseUrl = ref(window.$ossURL || '')
|
||||
const tupianTypes = ref({})
|
||||
const tupianUploading = ref(null)
|
||||
|
||||
const getFullUrl = (path) => {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return ossBaseUrl.value + (path.startsWith('/') ? path : '/' + path)
|
||||
}
|
||||
|
||||
const fetchGonggao = async () => {
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.gonggao, {
|
||||
phone: username.value,
|
||||
notice_type: 1,
|
||||
page_key: pageKey.value,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
gonggaoContent.value = res.data?.content || ''
|
||||
if (res.data?.scope === 'ALL_CLUBS') {
|
||||
scopeHint.value = '集团视图下仅可查看;修改轮播/公告请切换到具体子公司'
|
||||
} else if (res.data?.club_id) {
|
||||
scopeHint.value = `以下配置仅作用于俱乐部 ${res.data.club_id}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取公告失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onPageKeyChange = async () => {
|
||||
await Promise.all([fetchLunbo(), fetchGonggao()])
|
||||
}
|
||||
|
||||
const fetchLunbo = async () => {
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.lunboList, {
|
||||
phone: username.value,
|
||||
page_key: pageKey.value,
|
||||
})
|
||||
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: '打手个人中心背景' },
|
||||
]
|
||||
}
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取轮播图失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取轮播图失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveGonggao = async () => {
|
||||
const content = (gonggaoContent.value || '').trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('公告内容不能为空')
|
||||
return
|
||||
}
|
||||
if (content.length > 300) {
|
||||
ElMessage.warning('公告内容不能超过300字')
|
||||
return
|
||||
}
|
||||
gonggaoSaving.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.gonggao, {
|
||||
phone: username.value,
|
||||
notice_type: 1,
|
||||
page_key: pageKey.value,
|
||||
content,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
gonggaoSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file) => {
|
||||
const raw = file.raw
|
||||
if (!raw.type?.startsWith('image/')) {
|
||||
ElMessage.error('只能上传图片')
|
||||
return
|
||||
}
|
||||
if (raw.size / 1024 / 1024 >= 5) {
|
||||
ElMessage.error('图片不能超过5MB')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('phone', username.value)
|
||||
formData.append('action', 'add')
|
||||
formData.append('page_key', pageKey.value)
|
||||
formData.append('file', raw)
|
||||
const res = await request.post(JITUAN_API.lunboManage, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
await fetchLunbo()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLunbo = async (url) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该轮播图?', '删除确认', { type: 'warning' })
|
||||
const res = await request.post(JITUAN_API.lunboManage, {
|
||||
phone: username.value,
|
||||
action: 'delete',
|
||||
page_key: pageKey.value,
|
||||
tupian_url: url,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
await fetchLunbo()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTupian = async () => {
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.tupianList, { phone: username.value })
|
||||
if (res.code === 0) {
|
||||
tupianTypes.value = res.data?.types || {}
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取杂项图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTupianUpload = async (file, imageType) => {
|
||||
const raw = file.raw
|
||||
if (!raw?.type?.startsWith('image/')) {
|
||||
ElMessage.error('只能上传图片')
|
||||
return
|
||||
}
|
||||
if (raw.size / 1024 / 1024 >= 5) {
|
||||
ElMessage.error('图片不能超过5MB')
|
||||
return
|
||||
}
|
||||
tupianUploading.value = imageType
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('phone', username.value)
|
||||
formData.append('action', 'add')
|
||||
formData.append('image_type', String(imageType))
|
||||
formData.append('file', raw)
|
||||
const res = await request.post(JITUAN_API.tupianManage, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
await fetchTupian()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
tupianUploading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTupian = async (imageType, item) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该图片?', '提示', { type: 'warning' })
|
||||
const res = await request.post(JITUAN_API.tupianManage, {
|
||||
phone: username.value,
|
||||
action: 'delete',
|
||||
image_type: imageType,
|
||||
id: item.id,
|
||||
image_url: item.image_url,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
await fetchTupian()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchGonggao()
|
||||
await fetchLunbo()
|
||||
await fetchTupian()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.carousel-manage {
|
||||
padding: 24px;
|
||||
background: #f0f2f6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.scope-extra-hint {
|
||||
margin: -4px 0 16px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.section-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-actions {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.lunbo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.lunbo-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.lunbo-item {
|
||||
width: 160px;
|
||||
text-align: center;
|
||||
}
|
||||
.lunbo-preview {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #a0a3ab;
|
||||
padding: 24px;
|
||||
}
|
||||
.no-permission {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.no-permission-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.no-permission-text {
|
||||
font-size: 20px;
|
||||
color: #4a4c53;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.no-permission-desc {
|
||||
color: #8c8f96;
|
||||
}
|
||||
.misc-hint {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tupian-block {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.tupian-block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.empty-tip.small {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
347
src/views/miniapp/DashouExam.vue
Normal file
347
src/views/miniapp/DashouExam.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="dashou-exam-admin">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
🔒 无权限(需在「角色管理」勾选 <strong>dsks666 打手接单考试管理</strong>,或 8080a 小程序配置)
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="club-toolbar">
|
||||
<span class="toolbar-label">管理俱乐部:</span>
|
||||
<el-select
|
||||
v-model="selectedClubId"
|
||||
class="club-select"
|
||||
filterable
|
||||
:disabled="!canSwitchClub && clubOptions.length <= 1"
|
||||
@change="loadAll"
|
||||
>
|
||||
<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="hint">考试与题库按俱乐部独立;在此切换即可管理各小程序,无需改顶栏视图</span>
|
||||
</div>
|
||||
|
||||
<el-card class="block" shadow="never" v-loading="loading">
|
||||
<template #header><strong>考试配置</strong></template>
|
||||
<el-form :model="config" label-width="140px">
|
||||
<el-form-item label="开启考试">
|
||||
<el-switch v-model="config.is_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每次抽题数">
|
||||
<el-input-number v-model="config.draw_count" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="至少答对题数">
|
||||
<el-input-number v-model="config.pass_count" :min="1" :max="config.draw_count" />
|
||||
<span class="hint">允许错题:{{ maxWrong }} 道(超过则整套重来)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="须开通会员">
|
||||
<el-switch v-model="config.require_member" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="savingCfg" @click="saveConfig">保存配置</el-button>
|
||||
<span class="hint">题库 {{ enabledPool }}/{{ poolCount }} 题已上架</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="block" shadow="never">
|
||||
<template #header>
|
||||
<div class="head-row">
|
||||
<strong>题库管理</strong>
|
||||
<el-button type="primary" size="small" @click="openEditor()">新增题目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="questions" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="stem" label="题干" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">{{ row.question_type === 2 ? '多选' : '单选' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">{{ row.is_enabled ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEditor(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeQuestion(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="editorVisible" :title="editor.id ? '编辑题目' : '新增题目'" width="720px" @close="resetEditor">
|
||||
<el-form :model="editor" label-width="100px">
|
||||
<el-form-item label="题干" required>
|
||||
<el-input v-model="editor.stem" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="题型">
|
||||
<el-radio-group v-model="editor.question_type">
|
||||
<el-radio :label="1">单选</el-radio>
|
||||
<el-radio :label="2">多选</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="i in 6" :key="i" :label="`选项${i}`">
|
||||
<div class="opt-row">
|
||||
<el-input v-model="editor[`option_${i}`]" placeholder="留空表示不使用该选项" />
|
||||
<el-checkbox v-model="editor[`correct_${i}`]">正确</el-checkbox>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="错题解析">
|
||||
<el-input v-model="editor.explanation" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上架">
|
||||
<el-switch v-model="editor.is_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editor.id" label="配图">
|
||||
<div class="img-grid">
|
||||
<div v-for="img in editor.images" :key="img.id" class="img-cell">
|
||||
<el-image :src="fullUrl(img.image_url)" fit="cover" class="thumb" />
|
||||
<el-button link type="danger" @click="deleteImage(img.id)">删</el-button>
|
||||
</div>
|
||||
<el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="(f) => uploadImage(f)">
|
||||
<el-button size="small">上传图片</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="hint">文字在上、图片在下展示</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editorVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingQ" @click="saveQuestion">保存</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 { JITUAN_API, getAdminClubContext, getAdminClubId } from '@/utils/club-context'
|
||||
|
||||
const username = localStorage.getItem('username') || ''
|
||||
const hasPermission = ref(true)
|
||||
const loading = ref(false)
|
||||
const savingCfg = ref(false)
|
||||
const savingQ = ref(false)
|
||||
const questions = ref([])
|
||||
const poolCount = ref(0)
|
||||
const enabledPool = ref(0)
|
||||
const clubOptions = ref([])
|
||||
const canSwitchClub = ref(false)
|
||||
const selectedClubId = ref('')
|
||||
const config = ref({
|
||||
is_enabled: false,
|
||||
draw_count: 10,
|
||||
pass_count: 10,
|
||||
require_member: true,
|
||||
})
|
||||
const maxWrong = computed(() => Math.max(0, config.value.draw_count - config.value.pass_count))
|
||||
const editorVisible = ref(false)
|
||||
const editor = ref(defaultEditor())
|
||||
const ossBase = window.$ossURL || ''
|
||||
|
||||
function defaultEditor() {
|
||||
const e = {
|
||||
id: null,
|
||||
stem: '',
|
||||
question_type: 1,
|
||||
explanation: '',
|
||||
is_enabled: true,
|
||||
images: [],
|
||||
}
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
e[`option_${i}`] = ''
|
||||
e[`correct_${i}`] = false
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
function fullUrl(p) {
|
||||
if (!p) return ''
|
||||
if (p.startsWith('http')) return p
|
||||
return ossBase + p
|
||||
}
|
||||
|
||||
function apiPayload(extra = {}) {
|
||||
return { phone: username, target_club_id: selectedClubId.value, ...extra }
|
||||
}
|
||||
|
||||
function initClubPicker(data) {
|
||||
if (data?.clubs?.length) {
|
||||
clubOptions.value = data.clubs
|
||||
canSwitchClub.value = Boolean(data.can_switch_club)
|
||||
} else {
|
||||
const ctx = getAdminClubContext()
|
||||
clubOptions.value = ctx?.clubs || []
|
||||
canSwitchClub.value = Boolean(ctx?.can_switch_club)
|
||||
}
|
||||
if (!selectedClubId.value) {
|
||||
selectedClubId.value = data?.club_id || getAdminClubId() || clubOptions.value[0]?.club_id || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!selectedClubId.value && clubOptions.value.length) {
|
||||
selectedClubId.value = clubOptions.value[0].club_id
|
||||
}
|
||||
if (!selectedClubId.value) {
|
||||
ElMessage.warning('暂无可见俱乐部,请在「数据范围配置」中分配任职')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const cfgRes = await request.post(JITUAN_API.dashouExamConfig, apiPayload({ action: 'get' }))
|
||||
if (cfgRes.code === 403) {
|
||||
hasPermission.value = false
|
||||
return
|
||||
}
|
||||
if (cfgRes.code === 0) {
|
||||
initClubPicker(cfgRes.data)
|
||||
if (cfgRes.data?.scope_error) {
|
||||
ElMessage.warning(cfgRes.data.scope_error)
|
||||
return
|
||||
}
|
||||
if (cfgRes.data?.config) {
|
||||
Object.assign(config.value, cfgRes.data.config)
|
||||
poolCount.value = cfgRes.data.pool_count || 0
|
||||
enabledPool.value = cfgRes.data.enabled_pool_count || 0
|
||||
if (cfgRes.data.club_id) selectedClubId.value = cfgRes.data.club_id
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(cfgRes.msg || '加载配置失败')
|
||||
return
|
||||
}
|
||||
const listRes = await request.post(JITUAN_API.dashouExamQuestionList, apiPayload())
|
||||
if (listRes.code === 0) {
|
||||
initClubPicker(listRes.data)
|
||||
questions.value = listRes.data?.list || []
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
savingCfg.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.dashouExamConfig, apiPayload({
|
||||
action: 'save',
|
||||
...config.value,
|
||||
}))
|
||||
if (res.code === 0) ElMessage.success('配置已保存')
|
||||
else ElMessage.error(res.msg || '保存失败')
|
||||
} finally {
|
||||
savingCfg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(row) {
|
||||
if (row) {
|
||||
editor.value = { ...defaultEditor(), ...row }
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
editor.value[`correct_${i}`] = Boolean(row[`correct_${i}`])
|
||||
}
|
||||
editor.value.images = row.images || []
|
||||
} else {
|
||||
editor.value = defaultEditor()
|
||||
}
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
editor.value = defaultEditor()
|
||||
}
|
||||
|
||||
async function saveQuestion() {
|
||||
savingQ.value = true
|
||||
try {
|
||||
const payload = apiPayload({ ...editor.value })
|
||||
payload.action = editor.value.id ? 'update' : 'create'
|
||||
if (editor.value.id) payload.question_id = editor.value.id
|
||||
const res = await request.post(JITUAN_API.dashouExamQuestionManage, payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
editorVisible.value = false
|
||||
await loadAll()
|
||||
} else ElMessage.error(res.msg || '保存失败')
|
||||
} finally {
|
||||
savingQ.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeQuestion(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除后不可恢复', '确认删除')
|
||||
const res = await request.post(JITUAN_API.dashouExamQuestionManage, apiPayload({
|
||||
action: 'delete',
|
||||
question_id: row.id,
|
||||
}))
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已删除')
|
||||
await loadAll()
|
||||
} else ElMessage.error(res.msg || '删除失败')
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!file?.raw || !editor.value.id) return
|
||||
const fd = new FormData()
|
||||
fd.append('phone', username)
|
||||
fd.append('target_club_id', selectedClubId.value)
|
||||
fd.append('question_id', editor.value.id)
|
||||
fd.append('file', file.raw)
|
||||
const res = await request.post(JITUAN_API.dashouExamImageManage, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
editor.value.images.push({ id: res.data.id, image_url: res.data.image_url })
|
||||
} else ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
|
||||
async function deleteImage(imageId) {
|
||||
const res = await request.post(JITUAN_API.dashouExamImageManage, apiPayload({
|
||||
action: 'delete',
|
||||
image_id: imageId,
|
||||
}))
|
||||
if (res.code === 0) {
|
||||
editor.value.images = editor.value.images.filter((i) => i.id !== imageId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const ctx = getAdminClubContext()
|
||||
clubOptions.value = ctx?.clubs || []
|
||||
canSwitchClub.value = Boolean(ctx?.can_switch_club)
|
||||
selectedClubId.value = getAdminClubId() || clubOptions.value[0]?.club_id || ''
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.club-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.toolbar-label { font-weight: 600; color: #303133; }
|
||||
.club-select { width: 280px; }
|
||||
.block { margin-bottom: 16px; }
|
||||
.head-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.hint { color: #888; font-size: 12px; }
|
||||
.opt-row { display: flex; gap: 12px; align-items: center; width: 100%; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.thumb { width: 80px; height: 80px; border-radius: 6px; }
|
||||
.no-permission { padding: 80px; text-align: center; }
|
||||
</style>
|
||||
176
src/views/miniapp/KaoheTagClub.vue
Normal file
176
src/views/miniapp/KaoheTagClub.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="club-kaohe-page">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</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="名称与 ID 由集团在「考核配置」定义。下架后抢单池标签筛选区不展示;订单卡片等处仍正常显示。"
|
||||
/>
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCatalog">上架标签</el-button>
|
||||
</div>
|
||||
<div class="tag-grid" v-loading="loading">
|
||||
<div v-for="item in list" :key="item.id" class="tag-card">
|
||||
<div class="tag-name">{{ item.mingcheng }}</div>
|
||||
<div class="tag-meta">ID {{ item.id }}</div>
|
||||
<el-button size="small" type="danger" plain @click="confirmDisable(item)">下架</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!loading && list.length === 0" description="尚未上架考核标签" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="catalogVisible" title="上架考核标签" width="520px">
|
||||
<div v-loading="catalogLoading">
|
||||
<el-empty v-if="!catalogLoading && catalog.length === 0" description="暂无可上架标签(请集团先在考核配置创建)" />
|
||||
<div v-for="c in catalog" :key="c.id" class="catalog-row">
|
||||
<span>{{ c.mingcheng }} (ID {{ c.id }})</span>
|
||||
<el-button size="small" type="primary" @click="enableItem(c)">上架</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
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 list = ref([])
|
||||
const catalogVisible = ref(false)
|
||||
const catalogLoading = ref(false)
|
||||
const catalog = ref([])
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
async function loadList() {
|
||||
if (isAllClubScope.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubKaoheList, { phone: username.value })
|
||||
if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
return
|
||||
}
|
||||
if (res.code === 0) {
|
||||
if (res.data?.scope_error) {
|
||||
ElMessage.warning(res.data.scope_error)
|
||||
list.value = []
|
||||
return
|
||||
}
|
||||
list.value = res.data?.list || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCatalog() {
|
||||
catalogVisible.value = true
|
||||
catalogLoading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubKaoheCatalog, { phone: username.value })
|
||||
if (res.code === 0) {
|
||||
if (res.data?.scope_error) {
|
||||
ElMessage.warning(res.data.scope_error)
|
||||
catalog.value = []
|
||||
return
|
||||
}
|
||||
catalog.value = res.data?.catalog || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载目录失败')
|
||||
}
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enableItem(c) {
|
||||
const res = await request.post(JITUAN_API.clubKaoheEnable, {
|
||||
phone: username.value,
|
||||
chenghao_id: c.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上架成功')
|
||||
catalogVisible.value = false
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上架失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDisable(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`下架「${item.mingcheng}」?抢单池标签筛选区将不再展示。`,
|
||||
'确认下架',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
const res = await request.post(JITUAN_API.clubKaoheDisable, {
|
||||
phone: username.value,
|
||||
chenghao_id: item.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已下架')
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '下架失败')
|
||||
}
|
||||
} catch {
|
||||
/* cancel */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tip { margin-bottom: 16px; }
|
||||
.scope-block { padding: 24px 0; }
|
||||
.action-bar { margin-bottom: 16px; }
|
||||
.tag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tag-card {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
.tag-name { font-weight: 600; margin-bottom: 6px; }
|
||||
.tag-meta { font-size: 12px; color: #888; margin-bottom: 10px; }
|
||||
.catalog-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.no-permission { text-align: center; padding: 80px 20px; }
|
||||
.no-permission-icon { font-size: 48px; }
|
||||
</style>
|
||||
267
src/views/miniapp/MiniappAssets.vue
Normal file
267
src/views/miniapp/MiniappAssets.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="miniapp-assets">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ClubScopeHint />
|
||||
<h2>小程序图标与频道</h2>
|
||||
<p class="hint">
|
||||
图标固定文件名存于 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 posterIcons" :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>
|
||||
<div class="icon-grid">
|
||||
<div v-for="item in funcIcons" :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>
|
||||
<el-form label-width="100px" class="pindao-form">
|
||||
<el-form-item label="频道号">
|
||||
<el-input v-model="pindao.channel_no" placeholder="展示在弹窗顶部" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="pindao.title" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="pindaoSaving" @click="savePindao">保存频道信息</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="pindao-images-head">
|
||||
<strong>频道图片</strong>
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="uploadPindaoImage"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
>
|
||||
<el-button size="small" type="primary" :loading="pindaoUploading">上传图片</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div v-if="pindaoImages.length" class="lunbo-grid">
|
||||
<div v-for="img in pindaoImages" :key="img.id" class="lunbo-item">
|
||||
<el-image :src="fullUrl(img.image_path)" fit="cover" class="lunbo-preview" />
|
||||
<el-button type="danger" link @click="deletePindaoImage(img.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">暂无频道图片</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
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 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 uploadingKey = ref('')
|
||||
const pindao = ref({ channel_no: '', title: '频道' })
|
||||
const pindaoImages = ref([])
|
||||
const pindaoSaving = ref(false)
|
||||
const pindaoUploading = ref(false)
|
||||
const ossBaseUrl = ref(window.$ossURL || '')
|
||||
|
||||
function fullUrl(path) {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
const base = ossBaseUrl.value || ''
|
||||
return base + (path.startsWith('/') ? path : path)
|
||||
}
|
||||
|
||||
async function loadIcons() {
|
||||
const res = await request.post(JITUAN_API.miniappIconList, { phone: username.value })
|
||||
if (res.code === 0) {
|
||||
icons.value = res.data?.icons || []
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载图标失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPindao() {
|
||||
const res = await request.post(JITUAN_API.pindaoManage, {
|
||||
phone: username.value,
|
||||
action: 'get',
|
||||
})
|
||||
if (res.code === 0) {
|
||||
pindao.value = { ...(res.data?.pindao || { channel_no: '', title: '频道' }) }
|
||||
pindaoImages.value = res.data?.images || []
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIcon(file, iconKey) {
|
||||
if (!file?.raw) return
|
||||
uploadingKey.value = iconKey
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('phone', username.value)
|
||||
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' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
await loadIcons()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function savePindao() {
|
||||
pindaoSaving.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.pindaoManage, {
|
||||
phone: username.value,
|
||||
action: 'save',
|
||||
channel_no: pindao.value.channel_no,
|
||||
title: pindao.value.title,
|
||||
})
|
||||
if (res.code === 0) ElMessage.success('保存成功')
|
||||
else ElMessage.error(res.msg || '保存失败')
|
||||
} finally {
|
||||
pindaoSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPindaoImage(file) {
|
||||
if (!file?.raw) return
|
||||
pindaoUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('phone', username.value)
|
||||
fd.append('file', file.raw)
|
||||
fd.append('action', 'add_image')
|
||||
const res = await request.post(JITUAN_API.pindaoManage, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上传成功')
|
||||
await loadPindao()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} finally {
|
||||
pindaoUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePindaoImage(id) {
|
||||
const res = await request.post(JITUAN_API.pindaoManage, {
|
||||
phone: username.value,
|
||||
action: 'delete_image',
|
||||
id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已删除')
|
||||
await loadPindao()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadIcons()
|
||||
await loadPindao()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.miniapp-assets { padding: 16px; }
|
||||
.hint { color: #666; font-size: 13px; margin-bottom: 16px; }
|
||||
.section-card { margin-bottom: 20px; }
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.icon-cell {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon-label { font-weight: 600; font-size: 13px; }
|
||||
.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; }
|
||||
.lunbo-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.lunbo-item { width: 120px; text-align: center; }
|
||||
.lunbo-preview { width: 120px; height: 120px; border-radius: 6px; }
|
||||
.empty-tip { color: #999; padding: 16px 0; }
|
||||
.pindao-form { max-width: 480px; }
|
||||
.pindao-images-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.no-permission { text-align: center; padding: 80px 20px; }
|
||||
</style>
|
||||
@@ -8,6 +8,9 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ClubScopeHint />
|
||||
<p v-if="scopeHint" class="scope-extra-hint">{{ scopeHint }}</p>
|
||||
|
||||
<div class="header-bar">
|
||||
<h2>弹窗公告管理</h2>
|
||||
<el-button type="primary" @click="openAddPageDialog">+ 添加页面</el-button>
|
||||
@@ -204,11 +207,13 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import upload from '@/utils/upload' // 使用你封装的 upload 模块
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
// ========== 全局配置 ==========
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const scopeHint = ref('')
|
||||
const pages = ref([])
|
||||
const activePopupIds = ref([])
|
||||
// OSS 基础 URL(用于拼接已有图片的完整地址)
|
||||
@@ -242,9 +247,14 @@ const getStrategyText = (popup) => {
|
||||
// ========== 数据获取 ==========
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await request.post('/houtai/hthqtcxx', { username: username.value })
|
||||
const res = await request.post(JITUAN_API.popupList, { username: username.value })
|
||||
if (res.code === 0) {
|
||||
pages.value = res.data.pages || []
|
||||
if (res.data.scope === 'ALL_CLUBS') {
|
||||
scopeHint.value = '集团视图下仅可查看;修改弹窗请切换到具体子公司'
|
||||
} else if (res.data.club_id) {
|
||||
scopeHint.value = `以下弹窗配置仅作用于俱乐部 ${res.data.club_id}`
|
||||
}
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
@@ -289,7 +299,7 @@ const submitPage = async () => {
|
||||
pageSubmitting.value = true
|
||||
try {
|
||||
const action = pageForm.id ? 'update_page' : 'add_page'
|
||||
const res = await request.post('/houtai/htxgtcxx', { username: username.value, action, ...pageForm })
|
||||
const res = await request.post(JITUAN_API.popupModify, { username: username.value, action, ...pageForm })
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(pageForm.id ? '修改成功' : '添加成功')
|
||||
pageDialogVisible.value = false
|
||||
@@ -303,7 +313,7 @@ const submitPage = async () => {
|
||||
const deletePage = async (page) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除页面“${page.name}”及其下所有内容?`, '删除确认', { type: 'warning' })
|
||||
const res = await request.post('/houtai/htxgtcxx', {
|
||||
const res = await request.post(JITUAN_API.popupModify, {
|
||||
username: username.value, action: 'delete_page', id: page.id, cascade_delete_popups: true
|
||||
})
|
||||
if (res.code === 0) {
|
||||
@@ -363,7 +373,7 @@ const submitPopup = async () => {
|
||||
popupSubmitting.value = true
|
||||
try {
|
||||
const action = popupForm.id ? 'update_popup' : 'add_popup'
|
||||
const res = await request.post('/houtai/htxgtcxx', {
|
||||
const res = await request.post(JITUAN_API.popupModify, {
|
||||
username: username.value, action, page_id: currentPageId.value, ...popupForm
|
||||
})
|
||||
if (res.code === 0) {
|
||||
@@ -379,7 +389,7 @@ const submitPopup = async () => {
|
||||
const deletePopup = async (popup, pageId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除弹窗“${popup.popup_id}”?`, '删除确认', { type: 'warning' })
|
||||
const res = await request.post('/houtai/htxgtcxx', {
|
||||
const res = await request.post(JITUAN_API.popupModify, {
|
||||
username: username.value, action: 'delete_popup', id: popup.id
|
||||
})
|
||||
if (res.code === 0) {
|
||||
@@ -473,7 +483,7 @@ const submitImage = async () => {
|
||||
imageSubmitting.value = true
|
||||
try {
|
||||
// 直接使用 request 发送 FormData(注意 headers 要设置 multipart/form-data)
|
||||
const res = await request.post('/houtai/htxgtcxx', formData, {
|
||||
const res = await request.post(JITUAN_API.popupModify, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
if (res.code === 0) {
|
||||
@@ -492,7 +502,7 @@ const submitImage = async () => {
|
||||
const deleteImage = async (img, popupId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该图片?', '删除确认', { type: 'warning' })
|
||||
const res = await request.post('/houtai/htxgtcxx', {
|
||||
const res = await request.post(JITUAN_API.popupModify, {
|
||||
username: username.value,
|
||||
action: 'delete_image',
|
||||
id: img.id
|
||||
@@ -512,6 +522,11 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-extra-hint {
|
||||
margin: -4px 0 16px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.popup-notice-manager {
|
||||
padding: 24px;
|
||||
background: #f0f2f6;
|
||||
|
||||
221
src/views/miniapp/ShangpinLeixingClub.vue
Normal file
221
src/views/miniapp/ShangpinLeixingClub.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="club-leixing-page">
|
||||
<div v-if="!hasPermission" class="no-permission">
|
||||
<div class="no-permission-icon">🔒</div>
|
||||
<div class="no-permission-text">您无权访问此页面</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="名称与 ID 由集团在「商品类型专区管理」定义。图标路径 club/{俱乐部ID}/miniapp/shangpinleixing/,各小程序互不影响。"
|
||||
/>
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCatalog">上架类型</el-button>
|
||||
</div>
|
||||
<div class="card-grid" v-loading="loading">
|
||||
<div v-for="item in list" :key="item.id" class="type-card">
|
||||
<el-image :src="fullUrl(item.tupian_url)" fit="contain" class="type-icon" />
|
||||
<div class="type-name">{{ item.jieshao }}</div>
|
||||
<div class="type-meta">ID {{ item.id }}</div>
|
||||
<div v-if="item.icon_source === 'club'" class="tag-club">本俱乐部图标</div>
|
||||
<div class="card-actions">
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(f) => uploadIcon(f, item.id)"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp"
|
||||
>
|
||||
<el-button size="small" :loading="uploadingId === item.id">更换图标</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" type="danger" plain @click="confirmDisable(item)">下架</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && list.length === 0" description="尚未上架商品类型,点击「上架类型」" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="catalogVisible" title="上架商品类型" width="520px">
|
||||
<div v-loading="catalogLoading">
|
||||
<el-empty v-if="!catalogLoading && catalog.length === 0" description="暂无可上架类型(请集团先在商品类型专区创建)" />
|
||||
<div v-for="c in catalog" :key="c.id" class="catalog-row">
|
||||
<span>{{ c.jieshao }} (ID {{ c.id }})</span>
|
||||
<el-button size="small" type="primary" @click="enableItem(c)">上架</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
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 list = ref([])
|
||||
const catalogVisible = ref(false)
|
||||
const catalogLoading = ref(false)
|
||||
const catalog = ref([])
|
||||
const uploadingId = ref(null)
|
||||
const ossBaseUrl = ref(window.$ossURL || '')
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
function fullUrl(path) {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return ossBaseUrl.value + path
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
if (isAllClubScope.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.clubLeixingList, { phone: username.value })
|
||||
if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
return
|
||||
}
|
||||
if (res.code === 0) {
|
||||
if (res.data?.scope_error) {
|
||||
ElMessage.warning(res.data.scope_error)
|
||||
list.value = []
|
||||
return
|
||||
}
|
||||
list.value = res.data?.list || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCatalog() {
|
||||
catalogVisible.value = true
|
||||
catalogLoading.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)
|
||||
catalog.value = []
|
||||
return
|
||||
}
|
||||
catalog.value = res.data?.catalog || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载目录失败')
|
||||
}
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enableItem(c) {
|
||||
const res = await request.post(JITUAN_API.clubLeixingEnable, {
|
||||
phone: username.value,
|
||||
leixing_id: c.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('上架成功')
|
||||
catalogVisible.value = false
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上架失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDisable(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`下架「${item.jieshao}」?抢单池将不再展示该类型。`, '确认下架', { type: 'warning' })
|
||||
const res = await request.post(JITUAN_API.clubLeixingDisable, {
|
||||
phone: username.value,
|
||||
leixing_id: item.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已下架')
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '下架失败')
|
||||
}
|
||||
} catch {
|
||||
/* cancel */
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIcon(file, leixingId) {
|
||||
if (!file?.raw) return
|
||||
uploadingId.value = leixingId
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('phone', username.value)
|
||||
fd.append('leixing_id', leixingId)
|
||||
fd.append('file', file.raw)
|
||||
const res = await request.post(JITUAN_API.clubLeixingIcon, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('图标已更新')
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tip { margin-bottom: 16px; }
|
||||
.scope-block { padding: 24px 0; }
|
||||
.action-bar { margin-bottom: 16px; }
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.type-card {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.type-icon { width: 88px; height: 88px; margin: 0 auto 8px; }
|
||||
.type-name { font-weight: 600; font-size: 14px; }
|
||||
.type-meta { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.tag-club { font-size: 11px; color: #e6a23c; margin-top: 4px; }
|
||||
.card-actions { margin-top: 10px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
|
||||
.catalog-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.no-permission { text-align: center; padding: 80px 20px; }
|
||||
.no-permission-icon { font-size: 48px; }
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ClubScopeHint />
|
||||
<div class="action-bar">
|
||||
<el-row :gutter="16" class="search-row">
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
|
||||
@@ -141,6 +142,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -223,7 +225,6 @@ const fetchOrders = async () => {
|
||||
username,
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
leixing: currentType.value || undefined,
|
||||
zhuangtai: currentStatus.value === 'all' ? undefined : currentStatus.value,
|
||||
dingdan_id: search.ordId || undefined,
|
||||
shangjia_id: search.shangjiaId || undefined,
|
||||
@@ -234,6 +235,9 @@ const fetchOrders = async () => {
|
||||
clkf: search.clkf || undefined,
|
||||
shangjia_nicheng: search.shangjiaNicheng || undefined
|
||||
}
|
||||
if (currentType.value !== '' && currentType.value !== null && currentType.value !== undefined) {
|
||||
params.leixing = currentType.value
|
||||
}
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key])
|
||||
|
||||
const res = await request.post('/yonghu/kfhqsjddlb', params)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ClubScopeHint />
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="action-bar">
|
||||
<el-row :gutter="16" class="search-row">
|
||||
@@ -146,6 +147,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -227,7 +229,6 @@ const fetchOrders = async () => {
|
||||
phone: localStorage.getItem('username'),
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
leixing: currentType.value || undefined,
|
||||
zhuangtai: currentStatus.value === 'all' ? undefined : currentStatus.value,
|
||||
dingdan_id: search.ordId || undefined,
|
||||
laoban_id: search.laobanId || undefined,
|
||||
@@ -237,6 +238,9 @@ const fetchOrders = async () => {
|
||||
jieshao: search.jieshao || undefined,
|
||||
clkf: search.clkf || undefined
|
||||
}
|
||||
if (currentType.value !== '' && currentType.value !== null && currentType.value !== undefined) {
|
||||
params.leixing = currentType.value
|
||||
}
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key])
|
||||
|
||||
const res = await request.post('/yonghu/kfhqddlb', params)
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAllClubScope" class="scope-block">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前为具体小程序视图"
|
||||
description="商品为集团全局数据,不可在此视图修改。请切换顶栏为「集团汇总(全部子公司)」后管理商品;本俱乐部抢单展示请在「小程序配置 → 抢单商品类型」中上架/换图标。"
|
||||
/>
|
||||
<el-button type="primary" link @click="$router.push('/miniapp/leixing')">前往抢单商品类型</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="action-bar">
|
||||
@@ -260,6 +271,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminClubScope } from '@/utils/club-context'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search, CircleClose } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -268,6 +280,7 @@ import Sortable from 'sortablejs'
|
||||
const router = useRouter()
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
// ---------- 基础数据 ----------
|
||||
const typeList = ref([]) // 原始数据,paixu 为后端倒序值(越大越靠前)
|
||||
@@ -762,6 +775,7 @@ 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) {
|
||||
@@ -805,6 +819,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-block { padding: 24px; }
|
||||
/* 整体容器 */
|
||||
.product-list-container {
|
||||
padding: 20px;
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<div class="no-permission-desc">请联系管理员开通权限</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAllClubScope" class="scope-block">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前为具体小程序视图"
|
||||
description="此处不可新增/编辑/删除全局商品类型。请切换顶栏为「集团汇总」后使用本页;或在「小程序配置 → 抢单商品类型」中仅做上架/下架与换图标。"
|
||||
/>
|
||||
<el-button type="primary" link @click="$router.push('/miniapp/leixing')">前往抢单商品类型</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 费率展示区域 -->
|
||||
<div class="rate-card">
|
||||
@@ -232,6 +243,9 @@ 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'
|
||||
|
||||
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ref, computed, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { uploadFiles } from '@/utils/upload'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
const props = defineProps({ visible: Boolean })
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
@@ -90,7 +91,7 @@ const submitForm = async () => {
|
||||
formData.append('yingxiang_qiangdan', form.yingxiang_qiangdan ? 1 : 0)
|
||||
selectedFiles.value.forEach(f => formData.append('tupian', f))
|
||||
|
||||
const res = await uploadFiles('/houtai/htfksc', selectedFiles.value, {
|
||||
const res = await uploadFiles(JITUAN_API.penaltyCreate, selectedFiles.value, {
|
||||
username: localStorage.getItem('username'),
|
||||
beichufa_id: form.beichufa_id,
|
||||
shenfen: form.shenfen,
|
||||
|
||||
@@ -76,6 +76,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { uploadFiles } from '@/utils/upload'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
@@ -125,7 +126,7 @@ const submitAction = async () => {
|
||||
|
||||
// 直接使用 request 发送亦可,这里使用 uploadFiles 并附带字段
|
||||
// 因为 uploadFiles 接受额外数据和文件,但需要注意文件字段名是 'tupian'
|
||||
const res = await uploadFiles('/houtai/glyclfk', selectedFiles.value, {
|
||||
const res = await uploadFiles(JITUAN_API.penaltyAction, selectedFiles.value, {
|
||||
username: localStorage.getItem('username') || '',
|
||||
fadan_id: props.fadan.id,
|
||||
action: currentAction.value,
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import FadanStats from './FadanStats.vue'
|
||||
import FadanList from './FadanList.vue'
|
||||
import FadanDetail from './FadanDetail.vue'
|
||||
@@ -90,7 +91,7 @@ const showAddDialog = ref(false)
|
||||
// 获取统计
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await request.post('/houtai/htglyhqcfsltj', { username })
|
||||
const res = await request.post(JITUAN_API.penaltyStats, { username })
|
||||
if (res.code === 0) Object.assign(stats, res.data)
|
||||
} catch (e) { console.error('统计获取失败', e) }
|
||||
}
|
||||
@@ -107,9 +108,12 @@ const fetchData = async (p = page.value) => {
|
||||
beichufa_shenfen: filterShenfen.value,
|
||||
sousuo: searchKey.value || undefined
|
||||
}
|
||||
const res = await request.post('/houtai/hthqfklb', params)
|
||||
const res = await request.post(JITUAN_API.penaltyList, params)
|
||||
if (res.code === 0) {
|
||||
list.value = res.data.list || []
|
||||
list.value = (res.data.list || []).map((item) => ({
|
||||
...item,
|
||||
create_time: item.create_time || item.creat_time || '',
|
||||
}))
|
||||
total.value = res.data.total || 0
|
||||
page.value = res.data.page || p
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UserScopeHint />
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
@@ -172,6 +173,8 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import UserScopeHint from '@/components/UserScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -270,7 +273,7 @@ const fetchGuanlis = async () => {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
|
||||
const res = await request.post('/houtai/hthqgslb', params)
|
||||
const res = await request.post(JITUAN_API.guanshiList, params)
|
||||
if (res.code === 0) {
|
||||
guanlis.value = res.data.list || []
|
||||
totalCount.value = res.data.total || 0
|
||||
|
||||
@@ -19,125 +19,141 @@
|
||||
|
||||
<!-- 正常展示 -->
|
||||
<div v-else class="detail-content">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<!-- 顶部概览 -->
|
||||
<div class="profile-hero">
|
||||
<div class="hero-left">
|
||||
<el-button :icon="ArrowLeft" circle @click="goBack" class="back-btn" />
|
||||
<h2 class="page-title">管事详情</h2>
|
||||
<el-avatar :src="getFullUrl(baseForm.avatar)" :size="72" class="hero-avatar" />
|
||||
<div class="hero-meta">
|
||||
<div class="hero-title-row">
|
||||
<h2 class="page-title">{{ baseForm.nickname || '未设置昵称' }}</h2>
|
||||
<el-tag :type="baseForm.zhuangtai === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ baseForm.zhuangtai === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="isZuzhang" type="warning" size="small">已是组长</el-tag>
|
||||
<el-tag v-else type="info" size="small">管事</el-tag>
|
||||
</div>
|
||||
<div class="hero-sub">
|
||||
<span>ID:{{ baseForm.yonghuid }}</span>
|
||||
<span v-if="baseForm.yaoqingma">邀请码:{{ baseForm.yaoqingma }}</span>
|
||||
<span>注册:{{ formatDate(baseForm.create_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
v-if="canPromoteZuzhang && !isZuzhang"
|
||||
type="warning"
|
||||
:loading="promoting"
|
||||
@click="handlePromoteZuzhang"
|
||||
>升级组长</el-button>
|
||||
<el-button v-if="!isEditMode" type="primary" @click="enterEditMode">编辑管事</el-button>
|
||||
<template v-else>
|
||||
<el-button type="success" @click="saveAllChanges" :loading="saving">保存所有修改</el-button>
|
||||
<el-button type="success" @click="saveAllChanges" :loading="saving">保存修改</el-button>
|
||||
<el-button @click="cancelEdit">取消</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基础信息卡片 -->
|
||||
<div class="info-card">
|
||||
<div class="card-title">基本信息</div>
|
||||
<el-form :model="baseForm" :disabled="!isEditMode" label-width="120px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用户ID">
|
||||
<el-input :value="baseForm.yonghuid" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="baseForm.nickname" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-upload">
|
||||
<el-avatar :src="getFullUrl(baseForm.avatar)" :size="60" />
|
||||
<el-button v-if="isEditMode" size="small" @click="changeAvatar">更换头像</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="baseForm.dianhua" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="微信号">
|
||||
<el-input v-model="baseForm.wechat" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="baseForm.zhuangtai">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="邀请码">
|
||||
<el-input :value="baseForm.yaoqingma" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="邀请人ID">
|
||||
<el-input :value="baseForm.yaoqingren || '无'" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="邀请二维码">
|
||||
<div v-if="baseForm.invite_qrcode_url" class="qrcode-preview">
|
||||
<img :src="getFullUrl(baseForm.invite_qrcode_url)" style="width:60px;height:60px;" />
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="注册时间">
|
||||
<el-input :value="formatDate(baseForm.create_time)" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-row :gutter="20" class="top-panels">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<!-- 基础信息卡片 -->
|
||||
<div class="info-card">
|
||||
<div class="card-title">基本信息</div>
|
||||
<el-form :model="baseForm" :disabled="!isEditMode" label-width="100px" class="compact-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户ID">
|
||||
<el-input :value="baseForm.yonghuid" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="baseForm.nickname" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="baseForm.dianhua" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="微信号">
|
||||
<el-input v-model="baseForm.wechat" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="baseForm.zhuangtai" style="width:100%">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邀请人ID">
|
||||
<div class="inviter-row">
|
||||
<el-input :value="baseForm.yaoqingren || '无'" disabled />
|
||||
<el-button
|
||||
v-if="canChangeInviter"
|
||||
type="primary"
|
||||
link
|
||||
@click="openInviterDialog"
|
||||
>更换邀请人</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邀请码">
|
||||
<el-input :value="baseForm.yaoqingma" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邀请二维码">
|
||||
<div v-if="baseForm.invite_qrcode_url" class="qrcode-preview">
|
||||
<img :src="getFullUrl(baseForm.invite_qrcode_url)" alt="qrcode" />
|
||||
</div>
|
||||
<span v-else class="muted">无</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" v-if="isEditMode">
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-upload">
|
||||
<el-avatar :src="getFullUrl(baseForm.avatar)" :size="48" />
|
||||
<el-button size="small" @click="changeAvatar">更换头像</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- 统计信息卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="card-title">数据统计</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">邀请打手总数</div>
|
||||
<div class="stat-value">{{ baseForm.yaogingshuliang || 0 }}</div>
|
||||
<el-col :xs="24" :lg="10">
|
||||
<!-- 统计信息卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="card-title">数据统计</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">邀请打手总数</div>
|
||||
<div class="stat-value">{{ baseForm.yaogingshuliang || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">今日充值打手</div>
|
||||
<div class="stat-value">{{ baseForm.jinrichongzhi || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">本月充值打手</div>
|
||||
<div class="stat-value">{{ baseForm.jinyuechongzhi || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-box highlight">
|
||||
<div class="stat-label">充值分佣总额</div>
|
||||
<div class="stat-value">¥{{ formatMoney(baseForm.chongzhifenrun) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">今日充值打手数</div>
|
||||
<div class="stat-value">{{ baseForm.jinrichongzhi || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">本月充值打手数</div>
|
||||
<div class="stat-value">{{ baseForm.jinyuechongzhi || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">充值分佣总额</div>
|
||||
<div class="stat-value">¥{{ formatMoney(baseForm.chongzhifenrun) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 余额与提现卡片 -->
|
||||
<div class="balance-card">
|
||||
@@ -194,6 +210,10 @@
|
||||
:disabled="!isEditMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="hint dividend-rule-hint">
|
||||
第1次:会员默认分成或下方「首次定制」。第2次及以后:仅当配置了对应次数分红才发放;
|
||||
若开启「永久分红」,则第2次起每次按永久金额发放(无需逐次添加)。未配置且未开永久则无分红。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次分红(默认) -->
|
||||
@@ -268,6 +288,11 @@
|
||||
<div v-if="isEditMode && extraTimesList.length === 0" class="empty-extra">
|
||||
<el-button type="primary" @click="addNewTimes">+ 添加第2次分红配置</el-button>
|
||||
</div>
|
||||
<div v-if="isEditMode && extraTimesList.length > 0" class="add-extra-bar">
|
||||
<el-button type="primary" plain @click="addNewTimes">
|
||||
+ 添加第{{ (extraTimesList.length ? Math.max(...extraTimesList) : 1) + 1 }}次分红配置
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="!isEditMode && extraTimesList.length === 0" class="empty-extra">暂无额外次数分红配置</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,12 +362,29 @@
|
||||
<el-button type="primary" @click="saveExtraDividend">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 更换邀请人弹窗 -->
|
||||
<el-dialog v-model="inviterDialogVisible" title="更换管事邀请人" width="420px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前邀请人">
|
||||
<span>{{ baseForm.yaoqingren || '无' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新邀请人ID" required>
|
||||
<el-input v-model="newInviterId" placeholder="请输入组长用户ID(7位)" maxlength="7" />
|
||||
<div class="form-tip muted">新邀请人必须是已存在的组长用户</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="inviterDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitChangeInviter" :loading="inviterChanging">确认更换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, inject } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
@@ -357,6 +399,67 @@ const loading = ref(false)
|
||||
const hasPermission = ref(true)
|
||||
const guanli = ref(null) // 原始完整数据
|
||||
const allMembers = ref([]) // 所有会员列表(用于分红选择)
|
||||
const isZuzhang = ref(false)
|
||||
const promoting = ref(false)
|
||||
const kefuPermissions = inject('kefuPermissions', ref([]))
|
||||
const canPromoteZuzhang = computed(() => {
|
||||
const perms = kefuPermissions.value || []
|
||||
return perms.includes('sjzz666') || perms.includes('000001')
|
||||
})
|
||||
const canChangeInviter = computed(() => {
|
||||
const perms = kefuPermissions.value || []
|
||||
return perms.includes('4400a') || perms.includes('000001')
|
||||
})
|
||||
|
||||
const inviterDialogVisible = ref(false)
|
||||
const newInviterId = ref('')
|
||||
const inviterChanging = ref(false)
|
||||
|
||||
const openInviterDialog = () => {
|
||||
newInviterId.value = ''
|
||||
inviterDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitChangeInviter = async () => {
|
||||
const newId = newInviterId.value.trim()
|
||||
if (!newId) {
|
||||
ElMessage.warning('请输入新邀请人ID')
|
||||
return
|
||||
}
|
||||
if (newId === guanliId) {
|
||||
ElMessage.warning('不能将自己设为邀请人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将管事 ${guanliId} 的邀请人更换为 ${newId}?新上级必须是组长。`,
|
||||
'确认更换',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
inviterChanging.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/htghyqr', {
|
||||
username: username.value,
|
||||
yonghuid: guanliId,
|
||||
target_type: 'guanshi',
|
||||
new_yaoqingren: newId
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('邀请人更换成功')
|
||||
inviterDialogVisible.value = false
|
||||
await fetchGuanliDetail()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '更换失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('更换失败')
|
||||
} finally {
|
||||
inviterChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式
|
||||
const isEditMode = ref(false)
|
||||
@@ -470,6 +573,7 @@ const fetchGuanliDetail = async () => {
|
||||
permanentDividend.amount = data.permanent_amount
|
||||
customFirstDividend.value = data.custom_first_dividend || {}
|
||||
extraDividendMap.value = data.extra_dividend_map || {}
|
||||
isZuzhang.value = !!data.is_zuzhang
|
||||
} else if (res.code === 403) {
|
||||
hasPermission.value = false
|
||||
} else {
|
||||
@@ -692,12 +796,58 @@ const deleteTimes = (times) => {
|
||||
delete extraDividendMap.value[times]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handlePromoteZuzhang = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将管事 ${baseForm.yonghuid} 升级为组长吗?将自动创建组长扩展数据并生成邀请码。`,
|
||||
'升级组长',
|
||||
{ confirmButtonText: '确认升级', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
promoting.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/htgssjzz', {
|
||||
username: username.value,
|
||||
yonghuid: guanliId,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '升级成功')
|
||||
isZuzhang.value = true
|
||||
if (res.data?.yaoqingma) {
|
||||
ElMessageBox.alert(
|
||||
`组长邀请码:${res.data.yaoqingma}`,
|
||||
'升级完成',
|
||||
{ confirmButtonText: '知道了' }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '升级失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('升级失败')
|
||||
} finally {
|
||||
promoting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!username.value) {
|
||||
ElMessage.error('未登录,请重新登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!kefuPermissions.value?.length) {
|
||||
try {
|
||||
const permRes = await request.post('/houtai/kfhqltqx', { phone: username.value })
|
||||
if (permRes.code === 0) {
|
||||
kefuPermissions.value = permRes.data?.permissions || []
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
fetchGuanliDetail()
|
||||
})
|
||||
</script>
|
||||
@@ -710,6 +860,43 @@ onMounted(() => {
|
||||
min-height: 100vh;
|
||||
color: #c0e0ff;
|
||||
}
|
||||
.profile-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(18, 25, 35, 0.9);
|
||||
border: 1px solid #2a6f8f;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.hero-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid #2a6f8f;
|
||||
}
|
||||
.hero-meta { min-width: 0; }
|
||||
.hero-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hero-sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #8ab3cf;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -727,19 +914,42 @@ onMounted(() => {
|
||||
color: #6cc3e8;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #6cc3e8;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
.header-actions { display: flex; gap: 12px; }
|
||||
.header-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.top-panels { margin-bottom: 20px; }
|
||||
.info-card, .stats-card, .balance-card, .dividend-card {
|
||||
background: rgba(18,25,35,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid #2a6f8f;
|
||||
border-radius: 20px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
.compact-form :deep(.el-form-item) { margin-bottom: 16px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.muted { color: #8ab3cf; font-size: 13px; }
|
||||
.inviter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.inviter-row .el-input { flex: 1; }
|
||||
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.qrcode-preview img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a6f8f;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
@@ -755,9 +965,13 @@ onMounted(() => {
|
||||
.stat-box {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-box.highlight {
|
||||
grid-column: span 2;
|
||||
background: rgba(42, 111, 143, 0.25);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8ab3cf;
|
||||
|
||||
266
src/views/user/IdentityTag.vue
Normal file
266
src/views/user/IdentityTag.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="identity-tag-page">
|
||||
<ClubScopeHint />
|
||||
<h2>身份装饰标签</h2>
|
||||
<p class="hint">与考核称号、订单需求标签分离,仅用于个人页/抢单卡片展示。</p>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="标签库" name="tags">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openTagDialog()">新增标签</el-button>
|
||||
</div>
|
||||
<el-table :data="tagList" v-loading="tagLoading" border>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="颜色" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="color-dot" :style="{ background: row.color }" />
|
||||
{{ row.color }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="闪光" width="80">
|
||||
<template #default="{ row }">{{ row.flash_enabled ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">{{ row.status === 1 ? '启用' : '停用' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openTagDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="deleteTag(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户绑定" name="bind">
|
||||
<div class="toolbar bind-toolbar">
|
||||
<el-input v-model="bindKeyword" placeholder="用户UID" clearable style="width:160px" />
|
||||
<el-select v-model="bindShenfen" placeholder="身份" clearable style="width:120px">
|
||||
<el-option label="打手" :value="1" />
|
||||
<el-option label="管事" :value="2" />
|
||||
<el-option label="商家" :value="3" />
|
||||
<el-option label="组长" :value="4" />
|
||||
</el-select>
|
||||
<el-button @click="loadBinds">查询</el-button>
|
||||
<el-button type="primary" @click="openBindDialog">绑定</el-button>
|
||||
</div>
|
||||
<el-table :data="bindList" v-loading="bindLoading" border>
|
||||
<el-table-column prop="yonghuid" label="用户UID" width="100" />
|
||||
<el-table-column prop="shenfen_label" label="身份" width="80" />
|
||||
<el-table-column prop="tag_name" label="标签" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="unbind(row)">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="tagDialogVisible" :title="tagForm.id ? '编辑标签' : '新增标签'" width="420px">
|
||||
<el-form :model="tagForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="tagForm.name" maxlength="20" /></el-form-item>
|
||||
<el-form-item label="颜色"><el-color-picker v-model="tagForm.color" /></el-form-item>
|
||||
<el-form-item label="闪光"><el-switch v-model="tagForm.flash_enabled" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="tagForm.sort_order" :min="0" /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="tagForm.statusOn" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="tagDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tagSaving" @click="saveTag">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="bindDialogVisible" title="绑定标签" width="420px">
|
||||
<el-form :model="bindForm" label-width="80px">
|
||||
<el-form-item label="用户UID"><el-input v-model="bindForm.yonghuid" maxlength="7" /></el-form-item>
|
||||
<el-form-item label="身份">
|
||||
<el-select v-model="bindForm.shenfen" style="width:100%">
|
||||
<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="bindForm.tag_id" style="width:100%">
|
||||
<el-option v-for="t in tagList.filter(x => x.status === 1)" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="bindSaving" @click="saveBind">绑定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const activeTab = ref('tags')
|
||||
const tagList = ref([])
|
||||
const tagLoading = ref(false)
|
||||
const tagDialogVisible = ref(false)
|
||||
const tagSaving = ref(false)
|
||||
const tagForm = ref({ id: null, name: '', color: '#FFD700', flash_enabled: false, sort_order: 0, statusOn: true })
|
||||
|
||||
const bindList = ref([])
|
||||
const bindLoading = ref(false)
|
||||
const bindKeyword = ref('')
|
||||
const bindShenfen = ref(null)
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindSaving = ref(false)
|
||||
const bindForm = ref({ yonghuid: '', shenfen: 1, tag_id: null })
|
||||
|
||||
const loadTags = async () => {
|
||||
tagLoading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.identityTagList, { phone: username.value })
|
||||
if (res.code === 0) tagList.value = res.data?.list || []
|
||||
else ElMessage.error(res.msg || '加载失败')
|
||||
} catch {
|
||||
ElMessage.error('加载标签失败')
|
||||
} finally {
|
||||
tagLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openTagDialog = (row) => {
|
||||
if (row) {
|
||||
tagForm.value = {
|
||||
id: row.id, name: row.name, color: row.color,
|
||||
flash_enabled: row.flash_enabled, sort_order: row.sort_order, statusOn: row.status === 1,
|
||||
}
|
||||
} else {
|
||||
tagForm.value = { id: null, name: '', color: '#FFD700', flash_enabled: false, sort_order: 0, statusOn: true }
|
||||
}
|
||||
tagDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveTag = async () => {
|
||||
if (!tagForm.value.name?.trim()) {
|
||||
ElMessage.warning('请输入标签名')
|
||||
return
|
||||
}
|
||||
tagSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
phone: username.value,
|
||||
id: tagForm.value.id,
|
||||
name: tagForm.value.name.trim(),
|
||||
color: tagForm.value.color,
|
||||
flash_enabled: tagForm.value.flash_enabled,
|
||||
sort_order: tagForm.value.sort_order,
|
||||
status: tagForm.value.statusOn ? 1 : 0,
|
||||
}
|
||||
const res = await request.post(JITUAN_API.identityTagManage, payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
tagDialogVisible.value = false
|
||||
await loadTags()
|
||||
} else ElMessage.error(res.msg || '保存失败')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
tagSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTag = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除标签「${row.name}」?`, '提示', { type: 'warning' })
|
||||
const res = await request.post(JITUAN_API.identityTagManage, {
|
||||
phone: username.value, action: 'delete', id: row.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已删除')
|
||||
await loadTags()
|
||||
} else ElMessage.error(res.msg || '删除失败')
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadBinds = async () => {
|
||||
bindLoading.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.identityTagBindList, {
|
||||
phone: username.value,
|
||||
keyword: bindKeyword.value || undefined,
|
||||
shenfen: bindShenfen.value ?? undefined,
|
||||
})
|
||||
if (res.code === 0) bindList.value = res.data?.list || []
|
||||
else ElMessage.error(res.msg || '加载失败')
|
||||
} catch {
|
||||
ElMessage.error('加载绑定失败')
|
||||
} finally {
|
||||
bindLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openBindDialog = () => {
|
||||
bindForm.value = { yonghuid: '', shenfen: 1, tag_id: tagList.value.find(t => t.status === 1)?.id || null }
|
||||
bindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveBind = async () => {
|
||||
const f = bindForm.value
|
||||
if (!f.yonghuid || !f.tag_id) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
bindSaving.value = true
|
||||
try {
|
||||
const res = await request.post(JITUAN_API.identityTagBind, {
|
||||
phone: username.value, action: 'bind',
|
||||
yonghuid: f.yonghuid.trim(), shenfen: f.shenfen, tag_id: f.tag_id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('绑定成功')
|
||||
bindDialogVisible.value = false
|
||||
await loadBinds()
|
||||
} else ElMessage.error(res.msg || '绑定失败')
|
||||
} catch {
|
||||
ElMessage.error('绑定失败')
|
||||
} finally {
|
||||
bindSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const unbind = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定解绑?', '提示', { type: 'warning' })
|
||||
const res = await request.post(JITUAN_API.identityTagBind, {
|
||||
phone: username.value, action: 'unbind', id: row.id,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('已解绑')
|
||||
await loadBinds()
|
||||
} else ElMessage.error(res.msg || '解绑失败')
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTags()
|
||||
await loadBinds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.identity-tag-page { padding: 16px; }
|
||||
.hint { color: #909399; font-size: 13px; margin-bottom: 16px; }
|
||||
.toolbar { margin-bottom: 12px; }
|
||||
.bind-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.color-dot { display: inline-block; width: 14px; height: 14px; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UserScopeHint />
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
@@ -109,6 +110,8 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import UserScopeHint from '@/components/UserScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -137,7 +140,7 @@ const fetchPlayers = async () => {
|
||||
page_size: pageSize.value,
|
||||
}
|
||||
if (searchKeyword.value.trim()) params.keyword = searchKeyword.value.trim()
|
||||
const res = await request.post('/houtai/kefuhqdslb', params)
|
||||
const res = await request.post(JITUAN_API.dashouList, params)
|
||||
if (res.code === 0) {
|
||||
players.value = res.data.list || []
|
||||
totalCount.value = res.data.total || 0
|
||||
|
||||
@@ -305,6 +305,8 @@
|
||||
:disabled="!hasMemberPermission"
|
||||
>添加会员</el-button>
|
||||
</div>
|
||||
<div v-if="!hasMemberPermission" class="member-hint warn">需要 001ff 或超级管理权限</div>
|
||||
<div v-else class="member-hint">添加/移除会员请在弹窗点「确认」,与顶部「保存」无关</div>
|
||||
</template>
|
||||
<div class="member-list">
|
||||
<div v-for="member in memberList" :key="member.id" class="member-item">
|
||||
@@ -335,6 +337,14 @@
|
||||
<span class="copyable" @click="copyText(playerInfo.yaoqingren)">
|
||||
{{ playerInfo.yaoqingren || '--' }} <el-icon><CopyDocument /></el-icon>
|
||||
</span>
|
||||
<el-button
|
||||
v-if="canChangeInviter"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="openInviterDialog"
|
||||
>更换邀请人</el-button>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍">
|
||||
<el-tooltip v-if="playerInfo.jieshao" :content="playerInfo.jieshao" placement="top">
|
||||
@@ -348,6 +358,23 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 更换邀请人弹窗 -->
|
||||
<el-dialog v-model="inviterDialogVisible" title="更换打手邀请人" width="420px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前邀请人">
|
||||
<span>{{ playerInfo.yaoqingren || '无' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新邀请人ID" required>
|
||||
<el-input v-model="newInviterId" placeholder="请输入管事用户ID(7位)" maxlength="7" />
|
||||
<div class="form-tip">新邀请人必须是已存在的管事用户</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="inviterDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitChangeInviter" :loading="inviterChanging">确认更换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加会员弹窗 -->
|
||||
<el-dialog v-model="memberDialogVisible" title="添加会员" width="400px">
|
||||
<el-form :model="memberForm" label-width="80px">
|
||||
@@ -404,8 +431,63 @@ const memberAdding = ref(false)
|
||||
const userPermissions = ref([])
|
||||
|
||||
const isGoldTitle = computed(() => playerInfo.value.chenghao === '金牌选手')
|
||||
const hasMemberPermission = computed(() => userPermissions.value.includes('001ff') || userPermissions.value.includes('001gg'))
|
||||
const hasMemberPermission = computed(() =>
|
||||
userPermissions.value.includes('000001') ||
|
||||
userPermissions.value.includes('001ff') ||
|
||||
userPermissions.value.includes('001gg')
|
||||
)
|
||||
const hasChenghaoPermission = computed(() => userPermissions.value.includes('001hh'))
|
||||
const canChangeInviter = computed(() => userPermissions.value.includes('001ee') || userPermissions.value.includes('000001'))
|
||||
|
||||
const inviterDialogVisible = ref(false)
|
||||
const newInviterId = ref('')
|
||||
const inviterChanging = ref(false)
|
||||
|
||||
const openInviterDialog = () => {
|
||||
newInviterId.value = ''
|
||||
inviterDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitChangeInviter = async () => {
|
||||
const newId = newInviterId.value.trim()
|
||||
if (!newId) {
|
||||
ElMessage.warning('请输入新邀请人ID')
|
||||
return
|
||||
}
|
||||
if (newId === playerId.value) {
|
||||
ElMessage.warning('不能将自己设为邀请人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将打手 ${playerId.value} 的邀请人更换为 ${newId}?新上级必须是管事。`,
|
||||
'确认更换',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
inviterChanging.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/htghyqr', {
|
||||
username: username.value,
|
||||
yonghuid: playerId.value,
|
||||
target_type: 'dashou',
|
||||
new_yaoqingren: newId
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('邀请人更换成功')
|
||||
inviterDialogVisible.value = false
|
||||
await fetchDetail()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '更换失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('更换失败')
|
||||
} finally {
|
||||
inviterChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getFullImageUrl = (url) => {
|
||||
@@ -448,7 +530,10 @@ const fetchDetail = async () => {
|
||||
})
|
||||
if (res.code === 0) {
|
||||
const data = res.data
|
||||
playerInfo.value = data.user_info || {}
|
||||
playerInfo.value = {
|
||||
...(data.user_info || {}),
|
||||
create_time: data.user_info?.create_time || data.user_info?.CreateTime || '',
|
||||
}
|
||||
originalInfo.value = JSON.parse(JSON.stringify(playerInfo.value))
|
||||
memberList.value = data.member_list || []
|
||||
allMembers.value = data.all_members || []
|
||||
@@ -569,6 +654,13 @@ const handleChenghaoChange = async () => {
|
||||
|
||||
// 添加会员
|
||||
const openMemberDialog = () => {
|
||||
if (!hasMemberPermission.value) {
|
||||
ElMessage.warning('无添加会员权限(需要 001ff 或超级管理)')
|
||||
return
|
||||
}
|
||||
if (allMembers.value.length === 0) {
|
||||
ElMessage.warning('会员类型列表为空,请刷新页面后重试')
|
||||
}
|
||||
memberForm.huiyuan_id = ''
|
||||
memberForm.days = 30
|
||||
memberDialogVisible.value = true
|
||||
@@ -655,6 +747,14 @@ onMounted(() => {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.member-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.member-hint.warn {
|
||||
color: #e6a23c;
|
||||
}
|
||||
/* 其余样式保持不变(从原代码复制) */
|
||||
|
||||
|
||||
@@ -810,4 +910,9 @@ onMounted(() => {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UserScopeHint />
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
@@ -159,6 +160,8 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import UserScopeHint from '@/components/UserScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -221,7 +224,7 @@ const fetchMerchants = async () => {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
|
||||
const res = await request.post('/houtai/hqsjgllb', params)
|
||||
const res = await request.post(JITUAN_API.shangjiaList, params)
|
||||
if (res.code === 0) {
|
||||
merchants.value = res.data.list || []
|
||||
totalCount.value = res.data.total || 0
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
<el-tag :type="merchant.zhuangtai === 1 ? 'success' : 'danger'" size="large">
|
||||
{{ merchant.zhuangtai === 1 ? '正常' : '已封禁' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="merchant.is_youzhi_shangjia" type="warning" size="large" class="youzhi-tag">优质商家</el-tag>
|
||||
</div>
|
||||
<div class="youzhi-row">
|
||||
<span>金牌商家</span>
|
||||
<el-switch
|
||||
v-model="merchant.is_youzhi_shangjia"
|
||||
:loading="youzhiSaving"
|
||||
@change="handleYouzhiChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
<div v-if="merchant.dianhua"><el-icon><Phone /></el-icon> 电话:{{ merchant.dianhua }}</div>
|
||||
@@ -153,6 +162,7 @@ const merchantId = route.params.id
|
||||
const merchant = ref(null)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const youzhiSaving = ref(false)
|
||||
|
||||
// 余额弹窗
|
||||
const balanceDialogVisible = ref(false)
|
||||
@@ -213,7 +223,10 @@ const fetchMerchantDetail = async () => {
|
||||
}
|
||||
const res = await request.post('/houtai/hthqsjxq', { username, yonghuid: merchantId })
|
||||
if (res.code === 0) {
|
||||
merchant.value = res.data
|
||||
merchant.value = {
|
||||
...res.data,
|
||||
create_time: res.data?.create_time || res.data?.CreateTime || '',
|
||||
}
|
||||
} else if (res.code === 403) {
|
||||
ElMessage.error(res.msg)
|
||||
goBack()
|
||||
@@ -278,6 +291,31 @@ const submitBalanceChange = async () => {
|
||||
}
|
||||
|
||||
// 封禁
|
||||
const handleYouzhiChange = async (val) => {
|
||||
youzhiSaving.value = true
|
||||
try {
|
||||
const username = localStorage.getItem('username')
|
||||
const res = await request.post('/houtai/xgsjxx', {
|
||||
username,
|
||||
yonghuid: merchantId,
|
||||
action: 'set_youzhi',
|
||||
is_youzhi_shangjia: val,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.msg || '设置成功')
|
||||
merchant.value.is_youzhi_shangjia = val
|
||||
} else {
|
||||
merchant.value.is_youzhi_shangjia = !val
|
||||
ElMessage.error(res.msg || '设置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
merchant.value.is_youzhi_shangjia = !val
|
||||
ElMessage.error('网络错误')
|
||||
} finally {
|
||||
youzhiSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBan = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定封禁该商家?封禁后无法派单和登录。', '封禁确认', {
|
||||
@@ -384,6 +422,15 @@ onMounted(() => {
|
||||
}
|
||||
.uid { font-size: 18px; font-weight: 600; color: #1a2634; }
|
||||
.nickname { font-size: 14px; color: #6b7a88; }
|
||||
.youzhi-tag { margin-left: 8px; }
|
||||
.youzhi-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
font-size: 14px;
|
||||
color: #4a5a6e;
|
||||
}
|
||||
.contact-info {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UserScopeHint />
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
@@ -172,6 +173,8 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import UserScopeHint from '@/components/UserScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -300,7 +303,7 @@ const fetchLeaders = async () => {
|
||||
params.commission_value = commissionValue.value
|
||||
}
|
||||
|
||||
const res = await request.post('/houtai/hthqzzlb', params)
|
||||
const res = await request.post(JITUAN_API.zuzhangList, params)
|
||||
|
||||
if (res.code === 0) {
|
||||
leaders.value = res.data.list || []
|
||||
|
||||
@@ -194,6 +194,10 @@
|
||||
:disabled="!isEditMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="hint dividend-rule-hint">
|
||||
第1次:会员默认分成或下方「首次定制」。第2次及以后:仅当配置了对应次数分红才发放;
|
||||
若开启「永久分红」,则第2次起每次按永久金额发放(无需逐次添加)。未配置且未开永久则无分红。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次分红(默认) -->
|
||||
@@ -268,6 +272,11 @@
|
||||
<div v-if="isEditMode && extraTimesList.length === 0" class="empty-extra">
|
||||
<el-button type="primary" @click="addNewTimes">+ 添加第2次分红配置</el-button>
|
||||
</div>
|
||||
<div v-if="isEditMode && extraTimesList.length > 0" class="add-extra-bar">
|
||||
<el-button type="primary" plain @click="addNewTimes">
|
||||
+ 添加第{{ (extraTimesList.length ? Math.max(...extraTimesList) : 1) + 1 }}次分红配置
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="!isEditMode && extraTimesList.length === 0" class="empty-extra">暂无额外次数分红配置</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,7 +473,10 @@ const fetchZuzhangDetail = async () => {
|
||||
zuzhang.value = data
|
||||
allMembers.value = data.all_members || []
|
||||
// 填充基础表单
|
||||
Object.assign(baseForm, data.base)
|
||||
Object.assign(baseForm, {
|
||||
...data.base,
|
||||
create_time: data.base?.create_time || data.base?.CreateTime || '',
|
||||
})
|
||||
Object.assign(balanceForm, data.balance)
|
||||
permanentDividend.enabled = data.permanent_enabled
|
||||
permanentDividend.amount = data.permanent_amount
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="withdraw-audit">
|
||||
<ClubScopeHint />
|
||||
<!-- 顶部统计卡片 -->
|
||||
<el-row :gutter="20" class="stats-cards">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
|
||||
@@ -276,6 +277,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const STORAGE_KEY = 'withdraw_audit_filters'
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="settings-content">
|
||||
<ClubScopeHint />
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">提现设置</h2>
|
||||
<div class="page-header-left">
|
||||
<h2 class="page-title">提现设置</h2>
|
||||
<span v-if="settingsClubHint" class="club-settings-hint">{{ settingsClubHint }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="!isEditMode" type="primary" @click="enterEditMode">编辑设置</el-button>
|
||||
<template v-else>
|
||||
@@ -156,11 +160,14 @@ import { ref, reactive, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JITUAN_API } from '@/utils/club-context'
|
||||
import ClubScopeHint from '@/components/ClubScopeHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const hasPermission = ref(true)
|
||||
const loading = ref(false)
|
||||
const settingsClubHint = ref('')
|
||||
const isEditMode = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -425,9 +432,12 @@ const onModeChange = (val) => {
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/houtai/hthqtxpz', { username: username.value })
|
||||
const res = await request.post(JITUAN_API.withdrawGet, { username: username.value })
|
||||
if (res.code === 0) {
|
||||
const data = res.data || {}
|
||||
settingsClubHint.value = data.scope === 'ALL_CLUBS'
|
||||
? '集团视图下仅可查看各子公司配置;修改费率/限额请切换到具体子公司'
|
||||
: `以下费率与限额仅作用于俱乐部 ${data.club_id || 'xq'},与其他子公司互不影响`
|
||||
const mergedRates = mergeRates(data.rates)
|
||||
roles.value.forEach(role => {
|
||||
role.rate = mergedRates[role.type] || 0
|
||||
@@ -531,7 +541,7 @@ const saveSettings = async () => {
|
||||
order_rates: { ...editData.orderRates },
|
||||
withdraw_mode: editData.withdrawMode
|
||||
}
|
||||
const res = await request.post('/houtai/htxgtxsz', payload)
|
||||
const res = await request.post(JITUAN_API.withdrawUpdate, payload)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('保存成功')
|
||||
roles.value.forEach(role => {
|
||||
@@ -586,11 +596,27 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.page-header-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #6cc3e8;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.club-settings-hint {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.header-actions .el-button {
|
||||
background: rgba(0,0,0,0.5);
|
||||
|
||||
Reference in New Issue
Block a user