backup: kefu state before examiner-add feature on ExaminerManage

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

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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,

View File

@@ -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
View 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
View 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)
})
}

View File

@@ -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)
)

View File

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

View File

@@ -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('账号已被封禁')

View File

@@ -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; }

View 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>

View 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>

View 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>

View File

@@ -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;

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 || {})

View File

@@ -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 || {})

View File

@@ -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>

View File

@@ -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 || {

View File

@@ -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 || {})

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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;

View 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>

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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="请输入组长用户ID7位" 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;

View 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>

View File

@@ -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

View File

@@ -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="请输入管事用户ID7位" 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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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 || []

View File

@@ -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

View File

@@ -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'

View File

@@ -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);