11 Commits

45 changed files with 1722 additions and 1595 deletions
+6 -3
View File
@@ -1,3 +1,6 @@
# 开发环境
VITE_PORT = 5173
VITE_API_BASE_URL = http://localhost:3000/admin
# 开发环境配置
# 基础API地址
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
# 是否开启本地调试
LOCAL_DEBUG=true
+6 -2
View File
@@ -1,2 +1,6 @@
# 测试环境
VITE_API_BASE_URL = https://test-api.cpms.example.com/admin
# 测试环境配置
# 基础API地址
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
# 是否开启本地调试
LOCAL_DEBUG=false
+81
View File
@@ -9,11 +9,18 @@
"version": "0.0.0",
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@types/big.js": "^7.0.0",
"@yp-component/root": "^0.0.48",
"ant-design-vue": "^4.0.0",
"big.js": "^7.0.1",
"china-area-data": "^5.0.1",
"dayjs": "^1.11.21",
"echarts": "^5.6.0",
"express": "^5.2.1",
"html2canvas": "^1.4.1",
"qs": "^6.15.0",
"vue": "^3.3.0",
"vue-icons-plus": "^0.1.8",
"vue-router": "^4.2.0"
},
"devDependencies": {
@@ -1045,6 +1052,12 @@
"url": "https://ko-fi.com/dangreen"
}
},
"node_modules/@types/big.js": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/@types/big.js/-/big.js-7.0.0.tgz",
"integrity": "sha512-WfAGp7IbJvyB8EmWK4tJD24rJRAL6uVbw3LV/hJntFNam+os9KWKj0PzXo8rRRpjupYK8U0M8FoBB8dBhWF2dg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"dev": true,
@@ -1488,6 +1501,15 @@
"version": "3.5.39",
"license": "MIT"
},
"node_modules/@yp-component/root": {
"version": "0.0.48",
"resolved": "http://192.168.2.127:4873/@yp-component/root/-/root-0.0.48.tgz",
"integrity": "sha512-TOp8adAob3pMSfecQjEC4uVNCGqNWcShDjSQysd4pYY34i5ldFLRGSiJdpLsFG1xKkcSudiqEc/Si/ZfYoDSqA==",
"dependencies": {
"cropperjs": "^1.6.2",
"vue": "^3.5.25"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"license": "MIT",
@@ -1663,6 +1685,19 @@
"node": ">=6.0.0"
}
},
"node_modules/big.js": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/big.js/-/big.js-7.0.1.tgz",
"integrity": "sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/bigjs"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"license": "MIT",
@@ -1826,6 +1861,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/china-area-data": {
"version": "5.0.1",
"resolved": "http://192.168.2.127:4873/china-area-data/-/china-area-data-5.0.1.tgz",
"integrity": "sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"dev": true,
@@ -2095,6 +2136,12 @@
"typescript": ">=5"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "http://192.168.2.127:4873/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@@ -2221,6 +2268,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "http://192.168.2.127:4873/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
@@ -5597,6 +5654,12 @@
"typescript": ">=4.2.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "http://192.168.2.127:4873/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -5840,6 +5903,15 @@
"eslint": ">=6.0.0"
}
},
"node_modules/vue-icons-plus": {
"version": "0.1.8",
"resolved": "http://192.168.2.127:4873/vue-icons-plus/-/vue-icons-plus-0.1.8.tgz",
"integrity": "sha512-Xc4hDsD/oP9waSUf44nSaFBhUPo+QkpKclx0S7//5BRACpXymctbit02epek0VRW6nb81pR486XmxPP/ofm2yQ==",
"license": "MIT",
"peerDependencies": {
"vue": ">=2.7.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"license": "MIT",
@@ -6064,6 +6136,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "http://192.168.2.127:4873/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}
+2
View File
@@ -18,8 +18,10 @@
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@types/big.js": "^7.0.0",
"@yp-component/root": "^0.0.48",
"ant-design-vue": "^4.0.0",
"big.js": "^7.0.1",
"china-area-data": "^5.0.1",
"dayjs": "^1.11.21",
"echarts": "^5.6.0",
-33
View File
@@ -3,10 +3,6 @@
* 注意:/admin 前缀已通过 VITE_API_BASE_URL 统一注入(见 .env.*),此文件中只写业务路径。
*/
import { get, post } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { MOCK_EVENT_DETAIL } from '@/config/mock/eventDetail';
import { buildMockEventListPage } from '@/config/mock/eventList';
import { buildMockSignupListPage } from '@/config/mock/eventSignup';
import type {
EventListQueryParams,
SignupListQueryParams,
@@ -30,9 +26,6 @@ export type {
ApiResult,
} from './types';
/** 模拟延时 */
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* 赛事列表 - 列表分页
* GET /admin/manager/tm/page → /manager/tm/page
@@ -40,16 +33,6 @@ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
export async function getEventList(
params: EventListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return {
code: 200,
msg: 'success',
data: buildMockEventListPage(page, limit),
};
}
return get('/manager/tm/page', params as Record<string, any>);
}
@@ -58,14 +41,6 @@ export async function getEventList(
* GET /admin/manager/tm/info → /manager/tm/info
*/
export async function getEventDetail(id: string): Promise<ApiResult<TournamentAdminInfoVO>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
return {
code: 200,
msg: 'success',
data: { ...MOCK_EVENT_DETAIL },
};
}
return get('/manager/tm/info', { id });
}
@@ -76,14 +51,6 @@ export async function getEventDetail(id: string): Promise<ApiResult<TournamentAd
export async function getSignupList(
params: SignupListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminInfoSignupVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
return {
code: 200,
msg: 'success',
data: buildMockSignupListPage(params),
};
}
return get('/manager/tm/info/signupPage', params as Record<string, any>);
}
+14
View File
@@ -186,6 +186,20 @@ export interface SignupListQueryParams {
categoryId: string;
}
// ============================================================
// 赛事规程(对应 /admin/manager/tm/ruleInfo
// ============================================================
/** 赛事规程详情 */
export interface TournamentAdminRuleInfoVO {
/** 赛事规程文字内容 */
rulesDescr?: string;
/** 赛事规程图片 */
rulesDescrImg?: string;
/** 赛事规程图片列表 */
rulesDescrImgList?: string[];
}
// ============================================================
// 上下架(对应 /manager/tm/online
// ============================================================
+5
View File
@@ -0,0 +1,5 @@
import { post } from '@/utils/request';
export const login = (params: { phone: string; password: string }) => {
return post(`/op/login`, params);
};
-11
View File
@@ -4,7 +4,6 @@
import { get } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockOperationLogPage } from '@/config/mock/operationLogs';
import { buildMockEventOperationLogPage } from '@/config/mock/eventOperationLogs';
import type {
OperationLogQueryParams,
SysOperationLogVO,
@@ -49,16 +48,6 @@ export async function getOperationLogList(
export async function getOperationLogs(
params: OperationLogQueryParams,
): Promise<ApiResult<PageData<TournamentAdminOperationPageVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return {
code: 200,
msg: 'success',
data: buildMockEventOperationLogPage(page, limit, pickFilters(params)),
};
}
return get('/tournament/operation/page', params as Record<string, any>);
}
+93 -4
View File
@@ -1,16 +1,105 @@
import { get } from '@/utils/request';
import type { MenuNode, PermissionCode } from '@/types';
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
// ============================================================
// 权限数据缓存(/op/permission 只请求一次)
// ============================================================
/**
* 获取当前用户的菜单树(后端下发)
* Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试
*/
export function fetchMenuTree(): Promise<MenuNode[]> {
return get('/menu/tree');
let authDataPromise: Promise<AuthData> | null = null;
/**
* 权限数据(角色 + 权限码)
*/
export interface AuthData {
roleList: string[];
permsList: string[];
}
/**
* 获取当前用户的完整权限数据(角色列表 + 权限编码列表)
* 接口: GET /op/permission
* Promise 级缓存:并发调用共享同一个请求,失败时清除缓存以允许重试
*/
export function fetchAuthData(): Promise<AuthData> {
if (authDataPromise) return authDataPromise;
authDataPromise = get('/op/permission')
.then((res: any) => ({
roleList: res.data?.roleList || [],
permsList: res.data?.permsList || [],
}))
.catch((err) => {
authDataPromise = null;
throw err;
});
return authDataPromise;
}
/**
* 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据)
*/
export function clearAuthDataCache() {
authDataPromise = null;
}
/**
* 获取当前用户的权限编码列表
* 复用 fetchAuthData 的缓存,避免重复请求
*/
export function fetchPermissions(): Promise<PermissionCode[]> {
return get('/permission/list');
return fetchAuthData().then((data) => data.permsList);
}
// ============================================================
// 菜单树 — 基于权限过滤兜底路由
// ============================================================
/**
* 按用户拥有的权限码过滤菜单树
*
* 规则:
* - 节点有 permission 字段 → 权限码必须在 permsSet 中才保留
* - 节点无 permission 但有 children → 保留并递归过滤子节点
* - 节点被过滤且父节点过滤后无子节点、无 component → 父节点也移除
*/
function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set<string>): MenuNode[] {
return nodes
.map((node) => {
// 叶子节点:有 permission 则按权限过滤
if (node.permission) {
if (!permsSet.has(node.permission)) return null;
return { ...node };
}
// 分组节点(无 permission 有 children):递归过滤子节点
if (node.children && node.children.length > 0) {
const filteredChildren = filterMenuNodesByPermission(node.children, permsSet);
// 子节点全部被过滤且父节点无 component → 移除父节点
if (filteredChildren.length === 0 && !node.component) return null;
return { ...node, children: filteredChildren };
}
// 无 permission、无 children、无 component → 保留(如纯展示节点)
return { ...node };
})
.filter((node): node is MenuNode => node !== null);
}
/**
* 获取当前用户的菜单树(基于权限过滤兜底路由)
*
* 1. 调用 /op/permission 获取用户权限码
* 2. 用权限码过滤 FALLBACK_MENU_NODES
* 3. 返回过滤后的菜单树
*
* 异常时抛出,由调用方(menuStore.loadMenu)降级使用未过滤的兜底路由
*/
export function fetchMenuTree(): Promise<MenuNode[]> {
return fetchAuthData().then(({ permsList }) => {
const permsSet = new Set(permsList);
return filterMenuNodesByPermission(FALLBACK_MENU_NODES, permsSet);
});
}
+13
View File
@@ -34,6 +34,7 @@ import type { MenuNode } from '@/types';
* | externalLink | string | 否 | 外链地址 |
* | activeMenu | string | 否 | 高亮菜单路径 |
* | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) |
* | permission | string | 否 | 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 |
* | children | MenuNode[] | 否 | 子路由 |
*
* ### 关于 routes → menuItems 的转换
@@ -55,6 +56,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/list',
componentName: 'EventList',
icon: 'AiOutlineUnorderedList',
permission: 'events.list.view',
},
{
id: 'events_orders',
@@ -63,6 +65,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/orders',
componentName: 'EventOrders',
icon: 'AiOutlineShoppingCart',
permission: 'events.orders.view',
},
{
id: 'events_users',
@@ -71,6 +74,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/users',
componentName: 'EventUsers',
icon: 'AiOutlineTeam',
permission: 'events.users.view',
},
{
id: 'events_banner',
@@ -79,6 +83,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/banner',
componentName: 'EventBanner',
icon: 'AiOutlinePicture',
permission: 'events.banner.view',
},
{
id: 'events_logs',
@@ -87,6 +92,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/logs',
componentName: 'EventLogs',
icon: 'AiOutlineHistory',
permission: 'events.logs.view',
},
],
},
@@ -103,6 +109,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/withdraw',
componentName: 'FinanceWithdraw',
icon: 'AiOutlineMoneyCollect',
permission: 'finance.withdraw.view',
},
{
id: 'finance_wallet',
@@ -111,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/wallet',
componentName: 'FinanceWallet',
icon: 'AiOutlineWallet',
permission: 'finance.wallet.view',
},
{
id: 'finance_payments',
@@ -119,6 +127,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/payments',
componentName: 'FinancePayments',
icon: 'AiOutlinePayCircle',
permission: 'finance.payments.view',
},
{
id: 'finance_reports',
@@ -127,6 +136,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/reports',
componentName: 'FinanceReports',
icon: 'AiOutlineBarChart',
permission: 'finance.reports.view',
},
],
},
@@ -143,6 +153,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/users',
componentName: 'SystemUsers',
icon: 'AiOutlineUser',
permission: 'system.users.view',
},
{
id: 'system_roles',
@@ -151,6 +162,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/roles',
componentName: 'SystemRoles',
icon: 'AiOutlineSafetyCertificate',
permission: 'system.roles.view',
},
{
id: 'system_logs',
@@ -159,6 +171,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/logs',
componentName: 'SystemLogs',
icon: 'AiOutlineAudit',
permission: 'system.logs.view',
},
],
},
+1 -1
View File
@@ -6,7 +6,7 @@
*
* 对接后端接口后将此值改为 false 即可切换。
*/
export const USE_MOCK = true;
export const USE_MOCK = false;
/** 模拟网络延时(毫秒) */
export const MOCK_DELAY = 850;
-39
View File
@@ -1,39 +0,0 @@
/**
* 赛事详情弹窗假数据
*
* 使用页面:`src/pages/events/list/components/EventDetailModal.tsx`
* 对应接口:`GET /manager/tm/info`
* 类型:`TournamentAdminInfoVO`
*/
import type { TournamentAdminInfoVO } from '@/api/events/types';
export const MOCK_EVENT_DETAIL: TournamentAdminInfoVO = {
name: '第二届全国地学羽毛球邀请赛',
startTimeBegin: '2026-05-26 08:00',
startTimeEnd: '2026-05-31 22:00',
descr:
'赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍。',
signupTimeBegin: '2026-05-24 08:00',
signupTimeEnd: '2026-05-26 22:00',
venueArea: '北京市 北京市 东城区',
venue: '李宁体育馆',
venueNo: '李宁体育馆',
isPublic: '1',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createTime: '2026-05-26 08:00',
createNickname: '张三',
signupCount: '100',
dropoutCount: '1',
dtotalLimit: '1000',
matchTotalCount: '50',
matchCount: '50',
img: 'https://picsum.photos/seed/eventcover/600/300',
notice:
'赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告。',
categoryList: [
{ id: '1', name: '男子单打', type: '1' },
{ id: '2', name: '女子双打', type: '2' },
],
groupInfoList: ['男子小组1(循环赛)', '女子小组1(淘汰赛)'],
};
-268
View File
@@ -1,268 +0,0 @@
/**
* 赛事列表页假数据
*
* 使用页面:`src/pages/events/list/index.tsx`
* 对应接口:`GET /manager/tm/page`
* 类型:`TournamentAdminVO[]`
*/
import type { TournamentAdminVO, PageData } from '@/api/events/types';
export const MOCK_EVENT_LIST: TournamentAdminVO[] = [
{
id: 'EV202607010001',
name: '2026全国青少年羽毛球锦标赛暨体育文化交流大会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '张运营',
createPhone: '13800138001',
createTime: '2026-07-10 08:11:07',
status: '0',
signupCount: '128',
dropoutCount: '5',
rulesDescr: '一、赛事目的:推动羽毛球运动发展。\n二、参赛资格:身体健康、年龄符合要求。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [
'https://picsum.photos/seed/rule1/300/200',
'https://picsum.photos/seed/rule2/300/200',
],
},
{
id: 'EV202607010002',
name: '国际马拉松城市联赛',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '李管理',
createPhone: '13900139002',
createTime: '2026-07-09 11:28:20',
status: '1',
signupCount: '256',
dropoutCount: '12',
rulesDescr: '一、赛事目的:推动马拉松运动发展。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: ['https://picsum.photos/seed/rule1/300/200'],
},
{
id: 'EV202607010003',
name: '全民健身运动会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '王策划',
createPhone: '13700137003',
createTime: '2026-07-08 14:45:33',
status: '2',
signupCount: '512',
dropoutCount: '8',
rulesDescr: '一、赛事目的:促进全民健身。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: ['https://picsum.photos/seed/rule1/300/200'],
},
{
id: 'EV202607010004',
name: '2026全国青少年羽毛球锦标赛暨体育文化交流大会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '赵赛事',
createPhone: '13600136004',
createTime: '2026-07-07 18:02:46',
status: '3',
signupCount: '64',
dropoutCount: '3',
rulesDescr: '一、赛事目的:交流体育文化。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [],
},
{
id: 'EV202607010005',
name: '国际马拉松城市联赛',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '张运营',
createPhone: '13800138001',
createTime: '2026-07-06 21:19:59',
status: '4',
signupCount: '0',
dropoutCount: '0',
rulesDescr: '一、赛事目的:推动城市体育交流。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: ['https://picsum.photos/seed/rule1/300/200'],
},
{
id: 'EV202607010006',
name: '全民健身运动会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '李管理',
createPhone: '13900139002',
createTime: '2026-07-05 00:36:12',
status: '0',
signupCount: '128',
dropoutCount: '5',
rulesDescr: '一、赛事目的:增强全民体质。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [],
},
{
id: 'EV202607010007',
name: '2026全国青少年羽毛球锦标赛暨体育文化交流大会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '王策划',
createPhone: '13700137003',
createTime: '2026-07-04 03:53:25',
status: '1',
signupCount: '256',
dropoutCount: '12',
rulesDescr: '一、赛事目的:选拔青少年人才。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [
'https://picsum.photos/seed/rule1/300/200',
'https://picsum.photos/seed/rule2/300/200',
],
},
{
id: 'EV202607010008',
name: '国际马拉松城市联赛',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '赵赛事',
createPhone: '13600136004',
createTime: '2026-07-03 07:10:38',
status: '2',
signupCount: '512',
dropoutCount: '8',
rulesDescr: '一、赛事目的:连接城市与运动。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [],
},
{
id: 'EV202607010009',
name: '全民健身运动会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '张运营',
createPhone: '13800138001',
createTime: '2026-07-02 10:27:51',
status: '3',
signupCount: '64',
dropoutCount: '3',
rulesDescr: '一、赛事目的:全民参与、共享健康。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: ['https://picsum.photos/seed/rule1/300/200'],
},
{
id: 'EV202607010010',
name: '2026全国青少年羽毛球锦标赛暨体育文化交流大会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '李管理',
createPhone: '13900139002',
createTime: '2026-07-01 13:44:04',
status: '4',
signupCount: '0',
dropoutCount: '0',
rulesDescr: '一、赛事目的:培养后备力量。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [],
},
{
id: 'EV202607010011',
name: '国际马拉松城市联赛',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '1',
isRequireIdCardImg: '1',
isRequireIdCardNo: '1',
createNickname: '王策划',
createPhone: '13700137003',
createTime: '2026-06-30 17:01:17',
status: '0',
signupCount: '128',
dropoutCount: '5',
rulesDescr: '一、赛事目的:推广城市品牌。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [
'https://picsum.photos/seed/rule1/300/200',
'https://picsum.photos/seed/rule2/300/200',
'https://picsum.photos/seed/rule3/300/200',
],
},
{
id: 'EV202607010012',
name: '全民健身运动会',
startTimeBegin: '2026-08-12 09:00',
startTimeEnd: '2026-08-15 18:00',
venueArea: '广东省 深圳市 南山区',
venue: '深圳湾体育中心',
isPublic: '0',
isRequireIdCardImg: '0',
isRequireIdCardNo: '0',
createNickname: '赵赛事',
createPhone: '13600136004',
createTime: '2026-06-29 20:18:30',
status: '1',
signupCount: '256',
dropoutCount: '12',
rulesDescr: '一、赛事目的:丰富群众体育生活。',
rulesDescrImg: 'https://picsum.photos/seed/rules/300/200',
rulesDescrImgList: [],
},
];
/** 构建分页响应 */
export function buildMockEventListPage(page: number, limit: number): PageData<TournamentAdminVO> {
const start = (page - 1) * limit;
const list = MOCK_EVENT_LIST.slice(start, start + limit);
return { total: MOCK_EVENT_LIST.length, list };
}
-76
View File
@@ -1,76 +0,0 @@
/**
* 赛事操作日志假数据
*
* 类型内联,避免与 @/api/logs 循环引用。
*/
interface MockTournamentAdminOperationPageVO {
opName: string;
source: string;
nickname: string;
phone: string;
tournamentName: string;
obj: string;
content: string;
createDate: string;
}
interface MockEventOperationLogQueryParams {
source?: string;
sourceNickname?: string;
sourcePhone?: string;
tournamentName?: string;
}
interface MockPageData<T> {
total: number;
list: T[];
}
const MOCK_EVENT_LOGS: MockTournamentAdminOperationPageVO[] = [
{
opName: '创建赛事',
source: 'PC',
nickname: '张三',
phone: '13800000001',
tournamentName: '第二届全国地学羽毛球邀请赛',
obj: '赛事',
content: '创建赛事「第二届全国地学羽毛球邀请赛」',
createDate: '2026-07-30 08:00:00',
},
{
opName: '修改赛事',
source: '小程序',
nickname: '李四',
phone: '13800000002',
tournamentName: '春季马拉松大赛',
obj: '赛事',
content: '修改赛事基础信息',
createDate: '2026-07-29 10:12:00',
},
];
export function buildMockEventOperationLogPage(
page: number,
limit: number,
params: MockEventOperationLogQueryParams,
): MockPageData<MockTournamentAdminOperationPageVO> {
let filtered = [...MOCK_EVENT_LOGS];
if (params.source) {
const sourceLabel = params.source === '1' ? 'PC' : '小程序';
filtered = filtered.filter((item) => item.source === sourceLabel);
}
if (params.sourceNickname) {
filtered = filtered.filter((item) => item.nickname.includes(params.sourceNickname!));
}
if (params.sourcePhone) {
filtered = filtered.filter((item) => item.phone.includes(params.sourcePhone!));
}
if (params.tournamentName) {
filtered = filtered.filter((item) => item.tournamentName.includes(params.tournamentName!));
}
const total = filtered.length;
const start = (page - 1) * limit;
return { total, list: filtered.slice(start, start + limit) };
}
-100
View File
@@ -1,100 +0,0 @@
/**
* 赛事详情 — 人员列表假数据
*
* 使用页面:`src/pages/events/list/components/EventDetailModal.tsx`
* 对应接口:`GET /manager/tm/info/signupPage`
* 类型:`TournamentAdminInfoSignupVO[]`
*/
import type {
TournamentAdminInfoSignupVO,
PageData,
SignupListQueryParams,
} from '@/api/events/types';
export const MOCK_SIGNUP_LIST: TournamentAdminInfoSignupVO[] = [
{
realName: '张三',
gender: '1',
phone: '13800138001',
idCard: '110101199001011234',
idCardImg: 'https://picsum.photos/seed/idcard1/96/64',
idCardImgList: [
'https://picsum.photos/seed/idcard1a/96/64',
'https://picsum.photos/seed/idcard1b/96/64',
],
},
{
realName: '李四',
gender: '1',
phone: '13900139002',
idCard: '110101199002022345',
idCardImg: 'https://picsum.photos/seed/idcard2/96/64',
idCardImgList: [
'https://picsum.photos/seed/idcard2a/96/64',
'https://picsum.photos/seed/idcard2b/96/64',
],
},
{
realName: '王芳',
gender: '2',
phone: '13700137003',
idCard: '110101199103033456',
idCardImg: '',
idCardImgList: [],
},
{
realName: '赵六',
gender: '1',
phone: '13600136004',
idCard: '110101199204044567',
idCardImg: 'https://picsum.photos/seed/idcard4/96/64',
idCardImgList: ['https://picsum.photos/seed/idcard4a/96/64'],
},
{
realName: '孙七',
gender: '2',
phone: '13500135005',
idCard: '110101199305055678',
idCardImg: '',
idCardImgList: [],
},
{
realName: '周八',
gender: '1',
phone: '13400134006',
idCard: '110101199406066789',
idCardImg: 'https://picsum.photos/seed/idcard6/96/64',
idCardImgList: [
'https://picsum.photos/seed/idcard6a/96/64',
'https://picsum.photos/seed/idcard6b/96/64',
],
},
{
realName: '吴九',
gender: '2',
phone: '13300133007',
idCard: '110101199507077890',
idCardImg: '',
idCardImgList: [],
},
{
realName: '郑十',
gender: '1',
phone: '13200132008',
idCard: '110101199608088901',
idCardImg: 'https://picsum.photos/seed/idcard8/96/64',
idCardImgList: ['https://picsum.photos/seed/idcard8a/96/64'],
},
];
/** 构建分页响应 */
export function buildMockSignupListPage(
params: SignupListQueryParams,
): PageData<TournamentAdminInfoSignupVO> {
// mock 模式下忽略 categoryId 过滤,直接分页返回
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
const start = (page - 1) * limit;
const list = MOCK_SIGNUP_LIST.slice(start, start + limit);
return { total: MOCK_SIGNUP_LIST.length, list };
}
+2
View File
@@ -4,6 +4,7 @@ import { useEffect } from './useEffect';
import { useMenuStore } from '@/stores/menuStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useTabsStore } from '@/stores/tabsStore';
import { clearAuthDataCache } from '@/api/menu';
import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
@@ -54,6 +55,7 @@ function createAuth() {
clearTabs();
clearMenu();
clearPermissions();
clearAuthDataCache();
router.push('/login');
};
@@ -1,4 +1,4 @@
import { defineComponent, reactive, onMounted } from 'vue';
import { defineComponent, reactive, ref } from 'vue';
import { Modal, Descriptions, Image, Table, Pagination, Space, Spin } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
@@ -9,7 +9,7 @@ import {
type InnerCategoryVO,
type TournamentAdminInfoSignupVO,
} from '../model/services';
import { useState } from '@/hooks';
import { useState, useEffect } from '@/hooks';
import styles from './EventDetailModal.module.less';
interface EventDetailModalProps {
@@ -67,8 +67,8 @@ const PLAYER_COLUMNS = [
title: '证件图片',
key: 'idCardImgs',
width: 180,
customRender: ({ record }: { record: TournamentAdminInfoSignupVO }) =>
renderIdImages(record.idCardImgList, record.idCardImg),
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>-</span>,
},
];
@@ -82,17 +82,19 @@ export default defineComponent({
setup(props: EventDetailModalProps) {
// ===== 详情数据 =====
const [detail, setDetail] = useState<TournamentAdminInfoVO | null>(null);
const [detailLoading, setDetailLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
// ===== 每个组别的选手数据(key = categoryId =====
const categoryPlayers = reactive<Record<string, TournamentAdminInfoSignupVO[]>>({});
const categoryPlayerTotal = reactive<Record<string, number>>({});
const categoryPlayerLoading = reactive<Record<string, boolean>>({});
const categoryPlayerPage = reactive<Record<string, number>>({});
const categoryPlayers = ref<Record<string, TournamentAdminInfoSignupVO[]>>({});
const categoryPlayerTotal = ref<Record<string, number>>({});
const categoryPlayerLoading = ref<Record<string, boolean>>({});
const categoryPlayerPage = ref<Record<string, number>>({});
/** 获取某个组别的选手列表(分页) */
const fetchPlayers = async (categoryId: string, page: number) => {
categoryPlayerLoading[categoryId] = true;
categoryPlayerLoading.value[categoryId] = true;
// 触发响应式更新
categoryPlayerLoading.value = { ...categoryPlayerLoading.value };
try {
const res = await getSignupList({
categoryId,
@@ -100,14 +102,18 @@ export default defineComponent({
limit: String(PAGE_SIZE),
});
if (res.code == 200) {
categoryPlayers[categoryId] = res.data.list;
categoryPlayerTotal[categoryId] = res.data.total;
categoryPlayerPage[categoryId] = page;
categoryPlayers.value[categoryId] = res.data.list;
categoryPlayers.value = { ...categoryPlayers.value };
categoryPlayerTotal.value[categoryId] = res.data.total;
categoryPlayerTotal.value = { ...categoryPlayerTotal.value };
categoryPlayerPage.value[categoryId] = page;
categoryPlayerPage.value = { ...categoryPlayerPage.value };
}
} catch {
// mock 模式不会到这里,真实模式下由 API 层处理
} finally {
categoryPlayerLoading[categoryId] = false;
categoryPlayerLoading.value[categoryId] = false;
categoryPlayerLoading.value = { ...categoryPlayerLoading.value };
}
};
@@ -116,24 +122,19 @@ export default defineComponent({
fetchPlayers(categoryId, page);
};
// ===== 初始化 =====
onMounted(async () => {
/** 获取赛事详情 */
const fetchDetail = async (id: string) => {
setDetailLoading(true);
setDetail(null);
try {
const id = props.record?.id;
if (!id) {
message.error('缺少赛事ID');
return;
}
const res = await getEventDetail(String(id));
if (res.code == 200) {
const res = await getEventDetail(id);
if (res.code == 200 && res.data) {
setDetail(res.data);
// 为每个组别加载第一页选手
for (const cat of res.data.categoryList) {
categoryPlayers[cat.id] = [];
categoryPlayerTotal[cat.id] = 0;
categoryPlayerPage[cat.id] = 1;
categoryPlayers.value[cat.id] = [];
categoryPlayerTotal.value[cat.id] = 0;
categoryPlayerPage.value[cat.id] = 1;
fetchPlayers(cat.id, 1);
}
} else {
@@ -144,7 +145,14 @@ export default defineComponent({
} finally {
setDetailLoading(false);
}
});
};
// 弹窗打开且 有 record.id 时自动请求(useEffect 带 immediate: true,兼容 destroyOnClose 重建场景)
useEffect(() => {
if (props.visible && props.record?.id) {
fetchDetail(String(props.record.id));
}
}, [() => props.visible, () => props.record?.id]);
return () => (
<Modal
@@ -234,8 +242,8 @@ export default defineComponent({
</div>
<Table
columns={PLAYER_COLUMNS}
dataSource={categoryPlayers[cat.id] || []}
loading={categoryPlayerLoading[cat.id]}
dataSource={categoryPlayers.value[cat.id] || []}
loading={categoryPlayerLoading.value[cat.id]}
scroll={{ x: 'max-content', y: 200 }}
size="small"
pagination={false}
@@ -243,9 +251,9 @@ export default defineComponent({
/>
<div class={styles.paginationRow}>
<Pagination
current={categoryPlayerPage[cat.id] || 1}
current={categoryPlayerPage.value[cat.id] || 1}
pageSize={PAGE_SIZE}
total={categoryPlayerTotal[cat.id] || 0}
total={categoryPlayerTotal.value[cat.id] || 0}
showSizeChanger={false}
size="small"
onChange={(page: number) => handlePlayerPageChange(cat.id, page)}
@@ -1,36 +1,54 @@
import { defineComponent } from 'vue';
import { Modal, Image } from 'ant-design-vue';
import { defineComponent, ref } from 'vue';
import { Modal, Image, Spin } from 'ant-design-vue';
import { getRuleInfo } from '../model/services';
import type { TournamentAdminRuleInfoVO } from '@/api/events/types';
import { useEffect } from '@/hooks';
import styles from './EventRegulationModal.module.less';
interface EventRegulationModalProps {
visible: boolean;
/** 当前赛事记录(需包含 id 字段) */
record?: Record<string, any>;
onClose: () => void;
}
/** 规程内容 */
const REGULATION_TEXT = [
'一、赛事目的:推动羽毛球运动发展,提高运动员技术水平,增进体育文化交流。',
'二、参赛资格:凡身体健康、年龄符合要求的羽毛球爱好者均可报名参加。',
'三、比赛规则:采用中国羽毛球协会审定的最新《羽毛球竞赛规则》。',
'四、赛制安排:比赛分小组赛和淘汰赛两个阶段,小组赛采用单循环,淘汰赛采用单败淘汰。',
'五、器材要求:参赛运动员自备球拍,比赛用球由组委会统一提供。',
'六、裁判设置:每场比赛设主裁判1名、副裁判2名,确保比赛公平公正。',
].join('\n\n');
/** 规程图片(mock8 张示例图,4 列 grid */
const REGULATION_IMAGES = Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
src: `https://picsum.photos/seed/reg${i + 1}/300/200`,
alt: `规程图片 ${i + 1}`,
}));
export default defineComponent({
name: 'EventRegulationModal',
props: {
visible: { type: Boolean, default: false },
record: { type: Object, default: () => ({}) },
onClose: { type: Function, required: true },
},
setup(props: EventRegulationModalProps) {
const loading = ref(false);
const ruleData = ref<TournamentAdminRuleInfoVO | null>(null);
/** 调用 getRuleInfo 接口获取规程数据 */
const fetchRuleInfo = async (id: string) => {
loading.value = true;
ruleData.value = null;
try {
const res = await getRuleInfo(id);
if (res.code == 200 && res.data) {
ruleData.value = res.data;
} else {
ruleData.value = null;
}
} catch (e) {
console.error('获取赛事规程失我败:', e);
ruleData.value = null;
} finally {
loading.value = false;
}
};
// 弹窗打开且 有 record.id 时自动请求(useEffect 带 immediate: true,兼容 destroyOnClose 重建场景)
useEffect(() => {
if (props.visible && props.record?.id) {
fetchRuleInfo(props.record.id);
}
}, [() => props.visible, () => props.record?.id]);
return () => (
<Modal
visible={props.visible}
@@ -42,25 +60,52 @@ export default defineComponent({
destroyOnClose
wrapClassName={styles.eventRegulationModalMain}
>
{/* 规程内容 */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.textBlock}>{REGULATION_TEXT}</div>
</div>
<Spin spinning={loading.value}>
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
{ruleData.value ? (
<>
{/* 规程内容 */}
{ruleData.value.rulesDescr && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
</div>
)}
{/* 规程图片4 列 grid,点击可浏览 */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Image.PreviewGroup>
<div class={styles.imageGrid}>
{REGULATION_IMAGES.map((img) => (
<div key={img.id} class={styles.imageCell}>
<Image src={img.src} />
{/* 规程图片 */}
{(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Image.PreviewGroup>
<div class={styles.imageGrid}>
{ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => (
<div key={idx} class={styles.imageCell}>
<Image src={src} />
</div>
))}
</div>
</Image.PreviewGroup>
</div>
)}
{/* 无数据提示 */}
{!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && (
<div
style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}
>
</div>
)}
</>
) : (
!loading.value && (
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
</div>
))}
</div>
</Image.PreviewGroup>
</div>
)
)}
</div>
</Spin>
</Modal>
);
},
+110 -170
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import {
Button,
Input,
@@ -10,8 +10,6 @@ import {
Cascader,
Tooltip,
Pagination,
Modal,
message,
} from 'ant-design-vue';
import {
useEventListModel,
@@ -20,9 +18,9 @@ import {
ID_CARD_OPTIONS,
ID_CARD_IMAGE_OPTIONS,
} from './model/useEventListModel';
import { toggleEventOnline } from './model/services';
import { useEventListColumns } from './model/useEventListColumns';
import { regionOptions } from '@/utils/areaData';
import { useContainerSize, useState } from '@/hooks';
import { useContainerSize } from '@/hooks';
import EventRegulationModal from './components/EventRegulationModal';
import EventDetailModal from './components/EventDetailModal';
import EventAuditModal from './components/EventAuditModal';
@@ -31,85 +29,12 @@ import pageStyles from '@/assets/styles/pageLayout.module.less';
const { RangePicker } = DatePicker;
/**
* bodyCell 渲染函数
*/
function renderBodyCell({
column,
text,
record,
onView,
onViewRegulation,
onAudit,
onToggleShelf,
}: {
column: any;
text: any;
record: any;
onView: (record: any) => void;
onViewRegulation: (record: any) => void;
onAudit: (record: any) => void;
onToggleShelf: (record: any) => void;
}) {
if (column.dataIndex === 'name') {
const maxLen = 15;
const raw = text || '';
const display = raw.length > maxLen ? raw.slice(0, maxLen) + '...' : raw;
return raw.length > maxLen ? (
<Tooltip title={raw}>
<span>{display}</span>
</Tooltip>
) : (
<span>{display}</span>
);
}
if (column.key === 'regulation') {
return (
<a style={{ cursor: 'pointer' }} onClick={() => onViewRegulation(record)}>
</a>
);
}
if (column.key === 'action') {
const status = record.status;
return (
// 报名中(0) / 进行中(1):查看 + 下架
((status === '0' || status === '1') && (
<Space>
<Button type="link" size="small" onClick={() => onView(record)}>
</Button>
<Button type="link" size="small" onClick={() => onToggleShelf(record)}>
</Button>
</Space>
)) ||
// 已下架(4):查看 + 上架
(status === '4' && (
<Space>
<Button type="link" size="small" onClick={() => onView(record)}>
</Button>
<Button type="link" size="small" onClick={() => onToggleShelf(record)}>
</Button>
</Space>
)) || (
// 已结束(2) / 已删除(3):仅查看
<Button type="link" size="small" onClick={() => onView(record)}>
</Button>
)
);
}
return;
}
/**
* 赛事列表
* 赛事列表页面(纯视图层)
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:useEventListModel.ts(状态 + 业务逻辑 + 交互处理)
* - 视图层:useEventListColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
*/
export default defineComponent({
name: 'EventList',
@@ -118,94 +43,122 @@ export default defineComponent({
filterForm,
loading,
dataSource,
columns,
pagination,
regulationModalVisible,
detailModalVisible,
auditModalVisible,
currentRecord,
togglingId,
handleSearch,
handleReset,
handlePageChange,
openDetailModal,
openRegulationModal,
closeRegulationModal,
closeDetailModal,
closeAuditModal,
submitAudit,
handleToggleShelf,
} = useEventListModel();
const { columns } = useEventListColumns();
const { containerRef, height } = useContainerSize();
// 弹窗状态
const [regulationModalVisible, setRegulationModalVisible] = useState<boolean>(false);
const [detailModalVisible, setDetailModalVisible] = useState<boolean>(false);
const [auditModalVisible, setAuditModalVisible] = useState<boolean>(false);
const [currentRecord, setCurrentRecord] = useState<any>({});
const handleView = (record: any) => {
console.log('查看赛事:', record.id);
setCurrentRecord(record);
setDetailModalVisible(true);
/** 渲染赛事名称(超长省略 + Tooltip) */
const renderName = (text: string) => {
const maxLen = 15;
const raw = text || '';
const display = raw.length > maxLen ? raw.slice(0, maxLen) + '...' : raw;
return raw.length > maxLen ? (
<Tooltip title={raw}>
<span>{display}</span>
</Tooltip>
) : (
<span>{display}</span>
);
};
/** 上架 / 下架 通用 */
const handleToggleShelf = (record: any) => {
const isRemoved = record.status === '4';
const actionText = isRemoved ? '上架' : '下架';
Modal.confirm({
title: `${actionText}确认`,
content: `确认${actionText}"${record.name}"该赛事吗,下架后用户将无法搜索和报名该赛事,已有报名和订单不受影响。`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
const online = isRemoved ? '1' : '0';
try {
const res = await toggleEventOnline({ id: record.id, online });
if (res.code == 200) {
message.success(`${actionText}成功`);
handleSearch();
} else {
message.error(res.msg || `${actionText}失败`);
}
} catch (e: any) {
console.error(`${actionText}失败:`, e);
}
},
});
/** 渲染操作列(纯视图,逻辑在 model 的 handleToggleShelf */
const renderAction = (record: any) => {
const status = record.status;
if (status === '0' || status === '1') {
return (
<Space>
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
</Button>
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
</Space>
);
}
if (status === '4') {
return (
<Space>
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
</Button>
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
</Space>
);
}
return (
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
</Button>
);
};
/** 打开审核弹窗 */
const handleAudit = (record: any) => {
console.log('审核赛事:', record.id);
setCurrentRecord(record);
setAuditModalVisible(true);
};
/** 提交审核 */
const handleAuditSubmit = (payload: { approved: boolean; comment: string }) => {
console.log('提交审核:', currentRecord.value.id, payload);
// TODO: 替换为真实 API 调用
message.success('审核提交成功');
setAuditModalVisible(false);
};
const handleViewRegulation = (record: any) => {
console.log('查看规程:', record.id);
setCurrentRecord(record);
setRegulationModalVisible(true);
};
onMounted(() => {
handleSearch();
});
/** 最终表格列:模型列 + 赛事规程 + 操作 */
/** 完整表格列 */
const tableColumns = [
...columns,
{ title: '赛事规程', key: 'regulation', width: 100, align: 'center' as const },
...columns.map((col: any) => {
if (col.dataIndex === 'name') {
return {
...col,
customRender: ({ text }: { text: string }) => renderName(text),
};
}
return col;
}),
{
title: '赛事规程',
key: 'regulation',
width: 100,
align: 'center' as const,
customRender: ({ record }: { record: any }) => (
<a style={{ cursor: 'pointer' }} onClick={() => openRegulationModal(record)}>
</a>
),
},
{
title: '操作',
key: 'action',
width: 160,
fixed: 'right' as const,
align: 'center' as const,
customRender: ({ record }: { record: any }) => renderAction(record),
},
];
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
<div class={pageStyles.filter}>
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
<Form.Item label="创建时间" name="createDateRange">
@@ -308,7 +261,6 @@ export default defineComponent({
</Form>
</div>
{/* ===== 表格区 ===== */}
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
@@ -317,26 +269,14 @@ export default defineComponent({
loading={loading.value}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: any) =>
renderBodyCell({
...args,
onView: handleView,
onViewRegulation: handleViewRegulation,
onAudit: handleAudit,
onToggleShelf: handleToggleShelf,
}),
}}
</Table>
/>
</div>
{/* 独立分页,右下方 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
@@ -344,26 +284,26 @@ export default defineComponent({
</div>
</div>
{/* ===== 弹窗区 ===== */}
{regulationModalVisible.value && (
<EventRegulationModal
visible={regulationModalVisible.value}
onClose={() => setRegulationModalVisible(false)}
record={currentRecord.value}
onClose={closeRegulationModal}
/>
)}
{detailModalVisible.value && (
<EventDetailModal
visible={detailModalVisible.value}
record={currentRecord.value}
onClose={() => setDetailModalVisible(false)}
onClose={closeDetailModal}
/>
)}
{auditModalVisible.value && (
<EventAuditModal
visible={auditModalVisible.value}
record={currentRecord.value}
onClose={() => setAuditModalVisible(false)}
onSubmit={handleAuditSubmit}
onClose={closeAuditModal}
onSubmit={submitAudit}
/>
)}
</div>
+69
View File
@@ -0,0 +1,69 @@
/**
* 赛事列表页配置
* 职责:集中管理字段映射、选项配置等常量
*/
import type { EventListQueryParams } from './services';
export const FILTER_DEFAULTS = {
createDateRange: null as [string, string] | null,
startDateRange: null as [string, string] | null,
name: '',
createName: '',
status: '',
region: [] as string[],
isPublic: '',
idCardNo: '',
idCardImg: '',
};
export type FieldMapping = [
keyof typeof FILTER_DEFAULTS,
keyof EventListQueryParams,
((v: any) => any)?,
];
export const FIELD_MAPPINGS: FieldMapping[] = [
['name', 'name', (v) => v?.trim?.() ?? v],
['createName', 'createName', (v) => v?.trim?.() ?? v],
['status', 'status'],
['isPublic', 'isPublic'],
['idCardNo', 'idCardNo'],
['idCardImg', 'idCardImg'],
['region', 'areaName', (v) => v?.[v.length - 1]],
['createDateRange', 'createDate', (v) => v?.[0]],
['createDateRange', 'createDateEnd', (v) => v?.[1]],
['startDateRange', 'startDate', (v) => v?.[0]],
['startDateRange', 'startDateEnd', (v) => v?.[1]],
];
/** 赛事状态选项(value 对应 API status */
export const EVENT_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '报名中' },
{ value: '1', label: '进行中' },
{ value: '2', label: '已结束' },
{ value: '3', label: '已删除' },
{ value: '4', label: '已下架' },
] as const;
/** 公开活动选项(value 对应 API isPublic: 0=非公开,1=公开) */
export const PUBLIC_EVENT_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '公开' },
{ value: '0', label: '非公开' },
] as const;
/** 证件号选项(value 对应 API idCardNo: 0=无需,1=需要) */
export const ID_CARD_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '无需提供' },
{ value: '1', label: '需提供' },
] as const;
/** 证件图片选项(value 对应 API idCardImg: 0=无需,1=需要) */
export const ID_CARD_IMAGE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '无需提供' },
{ value: '1', label: '需提供' },
] as const;
+12 -43
View File
@@ -3,10 +3,6 @@
* 该页面使用的所有接口集中管理
*/
import { get, post } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { MOCK_EVENT_DETAIL } from '@/config/mock/eventDetail';
import { buildMockEventListPage } from '@/config/mock/eventList';
import { buildMockSignupListPage } from '@/config/mock/eventSignup';
import type {
EventListQueryParams,
SignupListQueryParams,
@@ -16,12 +12,14 @@ import type {
TournamentAdminVO,
TournamentAdminInfoVO,
TournamentAdminInfoSignupVO,
TournamentAdminRuleInfoVO,
} from '@/api/events/types';
export type {
TournamentAdminVO,
TournamentAdminInfoVO,
TournamentAdminInfoSignupVO,
TournamentAdminRuleInfoVO,
InnerCategoryVO,
EventListQueryParams,
SignupListQueryParams,
@@ -30,62 +28,33 @@ export type {
ApiResult,
} from '@/api/events/types';
// ============================================================
// URL 常量
// ============================================================
const eventList = '/manager/tm/page';
const eventDetail = '/manager/tm/info';
const signupList = '/manager/tm/info/signupPage';
const toggleOnline = '/manager/tm/online';
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
// ============================================================
// API 函数
// ============================================================
const eventList = '/admin/manager/tm/page';
const eventDetail = '/admin/manager/tm/info';
const signupList = '/admin/manager/tm/info/signupPage';
const toggleOnline = '/admin/manager/tm/online';
const ruleInfo = `/admin/manager/tm/ruleInfo`;
/** 赛事列表 - 列表分页 */
export async function getEventList(
params: EventListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return {
code: 200,
msg: 'success',
data: buildMockEventListPage(page, limit),
};
}
return get(eventList, params as Record<string, any>);
}
/** 赛事列表 - 赛事详情 */
export async function getEventDetail(id: string): Promise<ApiResult<TournamentAdminInfoVO>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
return {
code: 200,
msg: 'success',
data: { ...MOCK_EVENT_DETAIL },
};
}
return get(eventDetail, { id });
}
/** 赛事列表 - 规则详情 */
export async function getRuleInfo(id: string): Promise<ApiResult<TournamentAdminRuleInfoVO>> {
return get(ruleInfo, { id });
}
/** 赛事列表 - 赛事详情 - 人员列表 */
export async function getSignupList(
params: SignupListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminInfoSignupVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
return {
code: 200,
msg: 'success',
data: buildMockSignupListPage(params),
};
}
return get(signupList, params as Record<string, any>);
}
@@ -0,0 +1,116 @@
import { h } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import type { TournamentAdminVO } from './services';
/** 状态标签映射 */
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'0': { label: '报名中', tone: 'primary' },
'1': { label: '进行中', tone: 'cyan' },
'2': { label: '已结束', tone: 'neutral' },
'3': { label: '已删除', tone: 'neutral' },
'4': { label: '已下架', tone: 'orange' },
};
/** 0/1 → 显示文字 */
const formatDisplayValue = (value: string, map: Record<string, string>) => map[value] ?? value;
const ID_CARD_DISPLAY_MAP: Record<string, string> = { '0': '否', '1': '是' };
const IS_PUBLIC_DISPLAY_MAP: Record<string, string> = { '0': '非公开', '1': '公开' };
/**
* 赛事列表 - 表格列配置(视图层)
* 纯展示逻辑,不包含状态管理
*/
export function useEventListColumns() {
const columns = [
{ title: '赛事ID', dataIndex: 'id', key: 'id', width: 180 },
{
title: '赛事名称',
dataIndex: 'name',
key: 'name',
width: 160,
},
{
title: '赛事时间',
key: 'eventTime',
width: 260,
customRender: ({ record }: { record: TournamentAdminVO }) =>
`${record.startTimeBegin}${record.startTimeEnd}`,
},
{
title: '省市区',
dataIndex: 'venueArea',
key: 'venueArea',
width: 200,
},
{
title: '地点',
dataIndex: 'venue',
key: 'venue',
width: 180,
},
{
title: '是否公开',
dataIndex: 'isPublic',
key: 'isPublic',
width: 100,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, IS_PUBLIC_DISPLAY_MAP),
},
{
title: '是否提供证件号',
dataIndex: 'isRequireIdCardNo',
key: 'isRequireIdCardNo',
width: 160,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
},
{
title: '是否提供证件图片',
dataIndex: 'isRequireIdCardImg',
key: 'isRequireIdCardImg',
width: 160,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
},
{
title: '创建人昵称',
dataIndex: 'createNickname',
key: 'createNickname',
width: 110,
},
{
title: '创建人手机号',
dataIndex: 'createPhone',
key: 'createPhone',
width: 130,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
customRender: ({ text }: { text: string }) => {
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
{
title: '报名人数',
dataIndex: 'signupCount',
key: 'signupCount',
width: 90,
},
{
title: '取消报名人数',
dataIndex: 'dropoutCount',
key: 'dropoutCount',
width: 140,
},
];
return { columns };
}
+155 -260
View File
@@ -1,84 +1,37 @@
import { computed, reactive, toRef, Ref, h } from 'vue';
import { message } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import type { TournamentAdminVO, EventListQueryParams } from './services';
import { getEventList } from './services';
import { computed, reactive, toRef, Ref } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { useState, useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import type { TournamentAdminVO, EventListQueryParams, PageData, ApiResult } from './services';
import { getEventList, toggleEventOnline } from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
EVENT_STATUS_OPTIONS,
PUBLIC_EVENT_OPTIONS,
ID_CARD_OPTIONS,
ID_CARD_IMAGE_OPTIONS,
} from './config';
// ============================================================
// 常量
// ============================================================
/** 赛事状态选项(value 对应 API status: 不传=全部、0=报名中,1=进行中,2=已结束,3=已删除,4=已下架) */
export const EVENT_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '报名中' },
{ value: '1', label: '进行中' },
{ value: '2', label: '已结束' },
{ value: '3', label: '已删除' },
{ value: '4', label: '已下架' },
] as const;
/** 公开活动选项(value 对应 API isPublic: 0=非公开,1=公开) */
export const PUBLIC_EVENT_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '公开' },
{ value: '0', label: '非公开' },
] as const;
/** 证件号选项(value 对应 API idCardNo: 0=无需,1=需要) */
export const ID_CARD_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '无需提供' },
{ value: '1', label: '需提供' },
] as const;
/** 证件图片选项(value 对应 API idCardImg: 0=无需,1=需要) */
export const ID_CARD_IMAGE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '无需提供' },
{ value: '1', label: '需提供' },
] as const;
/** 状态标签映射 */
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'0': { label: '报名中', tone: 'primary' },
'1': { label: '进行中', tone: 'cyan' },
'2': { label: '已结束', tone: 'neutral' },
'3': { label: '已删除', tone: 'neutral' },
'4': { label: '已下架', tone: 'orange' },
};
/** 0/1 → 显示文字 */
const formatDisplayValue = (value: string, map: Record<string, string>) => map[value] ?? value;
const ID_CARD_DISPLAY_MAP: Record<string, string> = { '0': '否', '1': '是' };
const IS_PUBLIC_DISPLAY_MAP: Record<string, string> = { '0': '非公开', '1': '公开' };
// ============================================================
// Model
// ============================================================
export { EVENT_STATUS_OPTIONS, PUBLIC_EVENT_OPTIONS, ID_CARD_OPTIONS, ID_CARD_IMAGE_OPTIONS };
/**
* 赛事列表页数据模型
* 筛选字段名与 API query 参数(EventListQueryParams)保持一致,
* 仅日期区间 RangePicker 拆分为 start/end 两个字段、省市区 Cascader 取末级为 areaName。
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*/
export function useEventListModel() {
// ===== 筛选条件(key 名对齐 API 查询参数) =====
const filterForm = reactive({
createDateRange: null as [string, string] | null,
startDateRange: null as [string, string] | null,
name: '',
createName: '',
status: '',
region: [] as string[],
isPublic: '',
idCardNo: '',
idCardImg: '',
});
const filterForm = reactive({ ...FILTER_DEFAULTS });
const [regulationModalVisible, setRegulationModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [auditModalVisible, setAuditModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<any>({});
const [togglingId, setTogglingId] = useState('');
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// 可搜索字段防抖
const { debouncedValue: debouncedName } = useDebounce(toRef(filterForm, 'name') as Ref<string>, {
delay: 300,
});
@@ -87,215 +40,157 @@ export function useEventListModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminVO[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// ===== 表格列配置(dataIndex 对齐 TournamentAdminVO 字段) =====
const columns = [
{ title: '赛事ID', dataIndex: 'id', key: 'id', width: 180 },
{
title: '赛事名称',
dataIndex: 'name',
key: 'name',
width: 160,
},
{
title: '赛事时间',
key: 'eventTime',
width: 260,
customRender: ({ record }: { record: TournamentAdminVO }) =>
`${record.startTimeBegin}${record.startTimeEnd}`,
},
{
title: '省市区',
dataIndex: 'venueArea',
key: 'venueArea',
width: 200,
},
{
title: '地点',
dataIndex: 'venue',
key: 'venue',
width: 180,
},
{
title: '是否公开',
dataIndex: 'isPublic',
key: 'isPublic',
width: 100,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, IS_PUBLIC_DISPLAY_MAP),
},
{
title: '是否提供证件号',
dataIndex: 'isRequireIdCardNo',
key: 'isRequireIdCardNo',
width: 160,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
},
{
title: '是否提供证件图片',
dataIndex: 'isRequireIdCardImg',
key: 'isRequireIdCardImg',
width: 160,
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
},
{
title: '创建人昵称',
dataIndex: 'createNickname',
key: 'createNickname',
width: 110,
},
{
title: '创建人手机号',
dataIndex: 'createPhone',
key: 'createPhone',
width: 130,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
customRender: ({ text }: { text: string }) => {
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
{
title: '报名人数',
dataIndex: 'signupCount',
key: 'signupCount',
width: 90,
},
{
title: '取消报名人数',
dataIndex: 'dropoutCount',
key: 'dropoutCount',
width: 140,
},
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): EventListQueryParams => {
const params: EventListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
const { page, pageSize } = pagination.params.value;
if (filterForm.createDateRange) {
params.createDate = filterForm.createDateRange[0];
params.createDateEnd = filterForm.createDateRange[1];
}
if (filterForm.startDateRange) {
params.startDate = filterForm.startDateRange[0];
params.startDateEnd = filterForm.startDateRange[1];
}
if (filterForm.region.length > 0) {
params.areaName = filterForm.region[filterForm.region.length - 1];
}
if (filterForm.name.trim()) {
params.name = filterForm.name.trim();
}
if (filterForm.createName.trim()) {
params.createName = filterForm.createName.trim();
}
if (filterForm.status) {
params.status = filterForm.status;
}
if (filterForm.isPublic) {
params.isPublic = filterForm.isPublic;
}
if (filterForm.idCardNo) {
params.idCardNo = filterForm.idCardNo;
}
if (filterForm.idCardImg) {
params.idCardImg = filterForm.idCardImg;
}
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
return params;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
return { page: String(page), limit: String(pageSize), ...params } as EventListQueryParams;
};
// ===== 计算属性 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminVO>>>(() => getEventList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
// 提取列表数据
const listData = computed<PageData<TournamentAdminVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
const dataSource = computed(() => listData.value?.list || []);
// 同步分页总条数
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
const hasFilter = computed(() => {
const f = filterForm;
return (
filterForm.createDateRange !== null ||
filterForm.startDateRange !== null ||
debouncedName.value.trim() !== '' ||
debouncedCreator.value.trim() !== '' ||
filterForm.status !== '' ||
filterForm.region.length > 0 ||
filterForm.isPublic !== '' ||
filterForm.idCardNo !== '' ||
filterForm.idCardImg !== ''
!!f.createDateRange ||
!!f.startDateRange ||
!!debouncedName.value?.trim() ||
!!debouncedCreator.value?.trim() ||
!!f.status ||
f.region.length > 0 ||
!!f.isPublic ||
!!f.idCardNo ||
!!f.idCardImg
);
});
// ===== 方法 =====
const handleSearch = () => fetchList();
/** 查询 */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('赛事列表查询参数:', queryParams);
const res = await getEventList(queryParams);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (error: any) {
console.error('赛事列表查询失败:', error);
} finally {
setLoading(false);
}
}, 500);
/** 重置 */
const handleReset = useThrottleFn(() => {
filterForm.createDateRange = null;
filterForm.startDateRange = null;
filterForm.name = '';
filterForm.createName = '';
filterForm.status = '';
filterForm.region = [];
filterForm.isPublic = '';
filterForm.idCardNo = '';
filterForm.idCardImg = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
// 等待 debounce 生效后自动查询
setTimeout(() => handleSearch(), 350);
}, 500);
const handleReset = () => {
Object.assign(filterForm, FILTER_DEFAULTS);
pagination.reset();
setTimeout(fetchList, 350);
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
const openDetailModal = (record: any) => {
setCurrentRecord(record);
setDetailModalVisible(true);
};
const openRegulationModal = (record: any) => {
setCurrentRecord(record);
setRegulationModalVisible(true);
};
const openAuditModal = (record: any) => {
setCurrentRecord(record);
setAuditModalVisible(true);
};
const closeRegulationModal = () => setRegulationModalVisible(false);
const closeDetailModal = () => setDetailModalVisible(false);
const closeAuditModal = () => setAuditModalVisible(false);
// TODO
const submitAudit = (payload: { approved: boolean; comment: string }) => {
console.log('提交审核:', currentRecord.value.id, payload);
message.success('审核提交成功');
setAuditModalVisible(false);
};
const handleToggleShelf = (record: any) => {
const isRemoved = record.status === '4';
const actionText = isRemoved ? '上架' : '下架';
const descText = isRemoved
? `确认上架"${record.name}"吗?上架后用户可搜索和报名该赛事。`
: `确认下架"${record.name}"吗?下架后用户将无法搜索和报名该赛事。`;
Modal.confirm({
title: `${actionText}确认`,
content: descText,
okText: '确认',
cancelText: '取消',
onOk: async () => {
setTogglingId(record.id);
try {
const res = await toggleEventOnline({ id: record.id, online: isRemoved ? '1' : '0' });
if (res.code === 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
message.error(res.msg || `${actionText}失败`);
}
} catch (e: any) {
message.error(e?.message || `${actionText}失败`);
} finally {
setTogglingId('');
}
},
});
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
hasFilter,
regulationModalVisible,
detailModalVisible,
auditModalVisible,
currentRecord,
togglingId,
handleSearch,
handleReset,
handlePageChange,
openDetailModal,
openRegulationModal,
openAuditModal,
closeRegulationModal,
closeDetailModal,
closeAuditModal,
submitAudit,
handleToggleShelf,
};
}
+25 -32
View File
@@ -10,21 +10,14 @@ import {
Tooltip,
Pagination,
} from 'ant-design-vue';
import {
useLogModel,
ACTION_TYPE_OPTIONS,
ACTION_SOURCE_OPTIONS,
ACTION_SOURCE_MAP,
} from './model/useLogModel';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_SOURCE_OPTIONS } from './model/useLogModel';
import { useLogColumns } from './model/useLogColumns';
import { useState, useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import './index.module.less';
const { RangePicker } = DatePicker;
// ============================================================
// LogContentCell: 操作内容单元格
// ============================================================
const LogContentCell = defineComponent({
name: 'LogContentCell',
props: { text: { type: String, required: true } },
@@ -63,15 +56,14 @@ const LogContentCell = defineComponent({
},
});
// ============================================================
// bodyCell 渲染
// ============================================================
function renderBodyCell({ column, text }: { column: any; text: any }) {
const value = text || '-';
if (column.key === 'source') return <span>{ACTION_SOURCE_MAP[text] || value}</span>;
return <span>{value}</span>;
}
/**
* 操作日志页面(纯视图层)
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:useLogModel.ts(状态 + 业务逻辑 + 交互处理)
* - 视图层:useLogColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
*/
export default defineComponent({
name: 'EventLogs',
setup() {
@@ -79,17 +71,24 @@ export default defineComponent({
filterForm,
loading,
dataSource,
columns,
pagination,
handleSearch,
handleReset,
handlePageChange,
} = useLogModel();
const { columns } = useLogColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
onMounted(() => {
handleSearch();
});
/** 渲染 bodyCell */
const renderBodyCell = ({ column, text }: { column: any; text: any }) => {
if (column.key === 'content') {
const raw = text || '';
return raw ? <LogContentCell text={raw} /> : <span>-</span>;
}
// 其他列使用 columns 中的 customRender
return <span>{text || '-'}</span>;
};
return () => (
<div class={pageStyles.containerMain}>
@@ -182,21 +181,15 @@ export default defineComponent({
pagination={false}
>
{{
bodyCell: (args: any) => {
if (args.column.key === 'content') {
const raw = args.text || '';
return raw ? <LogContentCell text={raw} /> : <span>-</span>;
}
return renderBodyCell(args);
},
bodyCell: renderBodyCell,
}}
</Table>
</div>
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+75
View File
@@ -0,0 +1,75 @@
/**
* 操作日志页 - 配置文件
* 集中管理筛选默认值、字段映射、选项配置
*/
// ============================================================
// 筛选默认值
// ============================================================
export const FILTER_DEFAULTS = {
dateRange: null as [string, string] | null,
type: '',
source: '',
sourceNickname: '',
sourcePhone: '',
tournamentName: '',
} as const;
// ============================================================
// 字段映射配置
// 用于 buildQueryParams 函数,实现配置化参数转换
// ============================================================
type FieldMapping = [keyof typeof FILTER_DEFAULTS, string, ((v: any) => any)?];
export const FIELD_MAPPINGS: FieldMapping[] = [
['type', 'type'],
['source', 'source'],
['sourceNickname', 'sourceNickname', (v) => v?.trim?.() ?? v],
['sourcePhone', 'sourcePhone', (v) => v?.trim?.() ?? v],
['tournamentName', 'tournamentName', (v) => v?.trim?.() ?? v],
];
// 特殊字段映射(不在 FIELD_MAPPINGS 中)
export const DATE_RANGE_MAPPING = {
formKey: 'dateRange' as const,
beginKey: 'dateBegin' as const,
endKey: 'dateEnd' as const,
};
// ============================================================
// 选项配置
// ============================================================
/** 操作类型选项(type: 1=创建赛事,2=删除赛事,3=取消报名,4=编辑选手,5=对阵管理,6=批量修改组别) */
export const ACTION_TYPE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '创建赛事' },
{ value: '2', label: '删除赛事' },
{ value: '3', label: '取消报名' },
{ value: '4', label: '编辑选手' },
{ value: '5', label: '对阵管理' },
{ value: '6', label: '批量修改组别' },
] as const;
/** 操作类型文案映射 */
export const ACTION_TYPE_MAP: Record<string, string> = {
'1': '创建赛事',
'2': '删除赛事',
'3': '取消报名',
'4': '编辑选手',
'5': '对阵管理',
'6': '批量修改组别',
};
/** 操作来源选项(value 对应 API source: 1=PC2=小程序) */
export const ACTION_SOURCE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: 'PC' },
{ value: '2', label: '小程序' },
] as const;
/** 操作来源文案映射 */
export const ACTION_SOURCE_MAP: Record<string, string> = {
'1': 'PC',
'2': '小程序',
};
+1 -20
View File
@@ -3,8 +3,6 @@
* 该页面使用的所有接口集中管理
*/
import { get } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockEventOperationLogPage } from '@/config/mock/eventOperationLogs';
import type {
OperationLogQueryParams,
TournamentAdminOperationPageVO,
@@ -22,32 +20,15 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const operationLogs = '/tournament/operation/page';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const operationLogs = '/admin/manager/operation/page';
// ============================================================
// API 函数
// ============================================================
function pickFilters(params: OperationLogQueryParams): OperationLogQueryParams {
const { page: _page, limit: _limit, ...filters } = params;
return filters;
}
/** 赛事操作日志分页 */
export async function getOperationLogs(
params: OperationLogQueryParams,
): Promise<ApiResult<PageData<TournamentAdminOperationPageVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return {
code: 200,
msg: 'success',
data: buildMockEventOperationLogPage(page, limit, pickFilters(params)),
};
}
return get(operationLogs, params as Record<string, any>);
}
@@ -0,0 +1,77 @@
import { type VNodeChild } from 'vue';
import { ACTION_TYPE_MAP, ACTION_SOURCE_MAP } from './config';
const renderType = (text: string): string => ACTION_TYPE_MAP[text] || text || '-';
const renderSource = (text: string): string => ACTION_SOURCE_MAP[text] || text || '-';
/**
* 操作日志页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 操作内容列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
export function useLogColumns() {
const columns = [
{
title: '操作类型',
dataIndex: 'type',
key: 'type',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): VNodeChild => renderType(text),
},
{
title: '操作来源',
dataIndex: 'source',
key: 'source',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string }): VNodeChild => renderSource(text),
},
{
title: '操作人昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
align: 'center' as const,
},
{
title: '操作人手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
},
{
title: '赛事名称',
dataIndex: 'tournamentName',
key: 'tournamentName',
width: 200,
align: 'center' as const,
},
{
title: '操作对象',
dataIndex: 'obj',
key: 'obj',
width: 240,
align: 'center' as const,
},
{
title: '操作内容',
dataIndex: 'content',
key: 'content',
width: 360,
align: 'center' as const,
},
{
title: '操作时间',
dataIndex: 'createDate',
key: 'createDate',
width: 170,
align: 'center' as const,
},
];
return { columns };
}
+91 -94
View File
@@ -1,46 +1,44 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { message } from 'ant-design-vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { computed, reactive, toRef, Ref, type UnwrapRef } from 'vue';
import { useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, type UsePaginationReturn } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import {
getOperationLogs,
type TournamentAdminOperationPageVO,
type OperationLogQueryParams,
type PageData,
type ApiResult,
} from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
DATE_RANGE_MAPPING,
ACTION_TYPE_OPTIONS,
ACTION_TYPE_MAP,
ACTION_SOURCE_OPTIONS,
ACTION_SOURCE_MAP,
} from './config';
// ============================================================
// 常量
// ============================================================
export { ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP, ACTION_SOURCE_OPTIONS, ACTION_SOURCE_MAP };
/** 操作类型选项(TODO: API type 字段待定意,暂时留空) */
export const ACTION_TYPE_OPTIONS = [{ value: '', label: '全部' }] as const;
/** 操作来源选项(value 对应 API source: 1=PC2=小程序) */
export const ACTION_SOURCE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: 'PC' },
{ value: '2', label: '小程序' },
] as const;
/** 操作来源文案映射 */
export const ACTION_SOURCE_MAP: Record<string, string> = {
'1': 'PC',
'2': '小程序',
};
// ============================================================
// Model
// ============================================================
// 提取 Pagination Ref 类型
type PaginationRef = UnwrapRef<UsePaginationReturn>;
type PageSizeRef = PaginationRef['pageSize'];
/**
* 操作日志页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useLogColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useLogModel() {
// ===== 筛选条件(key 名对齐 API 查询参数) =====
const filterForm = reactive({
dateRange: null as [string, string] | null,
type: '',
source: '',
sourceNickname: '',
sourcePhone: '',
tournamentName: '',
});
const filterForm = reactive({ ...FILTER_DEFAULTS });
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'sourceNickname') as Ref<string>,
@@ -55,42 +53,59 @@ export function useLogModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminOperationPageVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 表格列配置(dataIndex 对齐 TournamentAdminOperationPageVO =====
const columns = [
{ title: '操作类型', dataIndex: 'opName', key: 'opName', width: 140 },
{ title: '操作来源', dataIndex: 'source', key: 'source', width: 100 },
{ title: '操作人昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '操作人手机号', dataIndex: 'phone', key: 'phone', width: 140 },
{ title: '赛事名称', dataIndex: 'tournamentName', key: 'tournamentName', width: 200 },
{ title: '操作对象', dataIndex: 'obj', key: 'obj', width: 240 },
{ title: '操作内容', dataIndex: 'content', key: 'content', width: 360 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 170 },
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): OperationLogQueryParams => {
const params: OperationLogQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.dateRange) {
params.dateBegin = filterForm.dateRange[0];
params.dateEnd = filterForm.dateRange[1];
const { page, pageSize } = pagination.params.value;
// 基础参数
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
// 时间范围特殊处理
const dateRange = filterForm[DATE_RANGE_MAPPING.formKey];
if (Array.isArray(dateRange) && dateRange.length === 2) {
params[DATE_RANGE_MAPPING.beginKey] = dateRange[0];
params[DATE_RANGE_MAPPING.endKey] = dateRange[1];
}
if (filterForm.type) params.type = filterForm.type;
if (filterForm.source) params.source = filterForm.source;
if (filterForm.sourceNickname.trim()) params.sourceNickname = filterForm.sourceNickname.trim();
if (filterForm.sourcePhone.trim()) params.sourcePhone = filterForm.sourcePhone.trim();
if (filterForm.tournamentName.trim()) params.tournamentName = filterForm.tournamentName.trim();
return params;
return { page: String(page), limit: String(pageSize), ...params } as OperationLogQueryParams;
};
// ===== 计算属性 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminOperationPageVO>>>(
() => getOperationLogs(buildQueryParams()),
{
refreshDeps: [],
formatResult: (res) => res,
},
);
// 提取列表数据
const listData = computed<PageData<TournamentAdminOperationPageVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
const dataSource = computed(() => listData.value?.list || []);
// 同步分页总条数
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
const hasFilter = computed(() => {
return (
filterForm.dateRange !== null ||
@@ -102,49 +117,31 @@ export function useLogModel() {
);
});
// ===== 方法 =====
const handleSearch = () => fetchList();
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const params = buildQueryParams();
console.log('操作日志查询参数:', params);
const res = await getOperationLogs(params);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (e: any) {
console.error('操作日志查询失败:', e);
} finally {
setLoading(false);
}
}, 500);
const handleReset = useThrottleFn(() => {
const handleReset = () => {
filterForm.dateRange = null;
filterForm.type = '';
filterForm.source = '';
filterForm.sourceNickname = '';
filterForm.sourcePhone = '';
filterForm.tournamentName = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
}, 500);
pagination.reset();
setTimeout(() => fetchList(), 0);
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination.pageSize as PageSizeRef)) {
pagination.setPageSize(pageSize);
}
fetchList();
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
hasFilter,
handleSearch,
@@ -1,7 +1,7 @@
import { defineComponent, computed, onMounted } from 'vue';
import { defineComponent, computed } from 'vue';
import { Modal, Table, Button, Pagination, Image, Space, Spin } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState } from '@/hooks';
import { useState, useEffect } from '@/hooks';
import {
getOrderDetail,
getOrderPlayerList,
@@ -19,7 +19,6 @@ interface OrderDetailModalProps {
onReRefund: () => void;
}
// ===== 选手表格列(对齐 TournamentAdminOrderInfoPageVO =====
const GENDER_MAP: Record<string, string> = { '1': '男', '2': '女' };
const formatGender = (v: string) => GENDER_MAP[v] ?? v;
const formatMoney = (v: string) => {
@@ -117,19 +116,18 @@ export default defineComponent({
visible: { type: Boolean, default: false },
orderNo: { type: String, default: '' },
uniqueId: { type: String, default: '' },
onClose: { type: Function, required: true },
onReRefund: { type: Function, required: true },
onClose: { type: Function, default: null },
onReRefund: { type: Function, default: null },
},
setup(props: OrderDetailModalProps) {
// ===== 数据状态 =====
const [detail, setDetail] = useState<TournamentAdminOrderInfoVO | null>(null);
const [detailLoading, setDetailLoading] = useState(true);
// ===== 选手列表分页状态 =====
const [players, setPlayers] = useState<TournamentAdminOrderInfoPageVO[]>([]);
const [playersLoading, setPlayersLoading] = useState(true);
const [playerPage, setPlayerPage] = useState(1);
const [playerTotal, setPlayerTotal] = useState(0);
const [refundPage, setRefundPage] = useState(1);
const [refundPageSize] = useState(2);
const playerPageSize = 5;
/** 获取选手列表(分页) */
@@ -152,12 +150,18 @@ export default defineComponent({
}
};
// ===== 退款记录分页 =====
const [refundPage, setRefundPage] = useState(1);
const [refundPageSize] = useState(2);
const refundRecords = computed<InnerRefundInfo[]>(() => detail.value?.refundInfoList || []);
const hasRefund = computed(() => refundRecords.value.length > 0);
const hasRefundFailed = computed(() => {
const records = refundRecords.value;
if (!records || !records.length) return false;
return records.some((r) => {
if (!r) return false;
const hasRefundAmount = parseFloat(r.refundAmount || '0') > 0;
const hasRefundTime = r.refundTime && r.refundTime !== '-';
return hasRefundAmount && !hasRefundTime;
});
});
const pagedRefunds = computed(() => {
const start = (refundPage.value - 1) * refundPageSize.value;
@@ -170,8 +174,7 @@ export default defineComponent({
return records.reduce((sum, r) => sum + parseFloat(r.refundAmount || '0'), 0);
});
// ===== 初始化:获取详情 + 选手列表第一页 =====
onMounted(async () => {
useEffect(async () => {
const params = { orderNo: props.orderNo, uniqueId: props.uniqueId };
const [detailRes] = await Promise.all([getOrderDetail(params), fetchPlayers(1)]);
@@ -180,7 +183,7 @@ export default defineComponent({
setDetail(detailRes.data);
}
setDetailLoading(false);
});
}, []);
const handlePlayerPageChange = (page: number) => {
fetchPlayers(page);
@@ -202,7 +205,6 @@ export default defineComponent({
wrapClassName={styles.orderModalWrap}
>
<div class={styles.orderDetailModalMain}>
{/* ===== 自定义标题栏 ===== */}
<div class={styles.modalHeader}>
<span class={styles.modalTitle}></span>
<button class={styles.closeBtn} onClick={props.onClose} aria-label="关闭">
@@ -216,7 +218,6 @@ export default defineComponent({
</div>
) : detail.value ? (
<div class={styles.modalBody}>
{/* ===== 订单信息 ===== */}
<div class={styles.infoGrid}>
<div class={styles.infoItem}>
<span class={styles.infoLabel}></span>
@@ -252,7 +253,6 @@ export default defineComponent({
</div>
</div>
{/* ===== 选手信息 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Table
@@ -276,7 +276,6 @@ export default defineComponent({
</div>
</div>
{/* ===== 退款记录 ===== */}
<div class={styles.section}>
<div class={styles.refundHeader}>
<span class={styles.sectionTitle}>退</span>
@@ -354,10 +353,10 @@ export default defineComponent({
</div>
) : null}
{/* ===== 底部按钮 ===== */}
<div class={styles.modalFooter}>
<Button onClick={props.onClose}></Button>
{hasRefund.value ? (
{/* 只有存在退款失败记录时才显示重新退款按钮 */}
{hasRefundFailed.value ? (
<Button danger onClick={props.onReRefund}>
退
</Button>
+7 -9
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import {
Button,
Input,
@@ -80,6 +80,8 @@ function renderBodyCell({
}
if (column.key === 'action') {
// 退款状态:1=无退款,2=退款成功,3=退款失败,-1=其他
// 只有退款失败(3)时显示重新退款按钮
const showReRefund = record.refundStatus === 3;
return (
<Space>
@@ -143,10 +145,6 @@ export default defineComponent({
},
];
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
@@ -264,12 +262,12 @@ export default defineComponent({
</Table>
</div>
{/* 独立分页,右下方 */}
{/* 独立分页 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+57
View File
@@ -0,0 +1,57 @@
/**
* 订单管理页配置
* 职责:集中管理字段映射、选项配置等常量
*/
import type { OrderListQueryParams } from './services';
// ============================================================
// 筛选字段默认值
// ============================================================
export const FILTER_DEFAULTS = {
orderDateRange: null as [string, string] | null,
orderNo: '',
tName: '',
sName: '',
status: '',
refundStatus: '',
};
// ============================================================
// 字段映射配置
// ============================================================
export type FieldMapping = [
keyof typeof FILTER_DEFAULTS,
keyof OrderListQueryParams,
((v: any) => any)?,
];
export const FIELD_MAPPINGS: FieldMapping[] = [
['orderNo', 'orderNo', (v) => v?.trim?.() ?? v],
['tName', 'tName', (v) => v?.trim?.() ?? v],
['sName', 'sName', (v) => v?.trim?.() ?? v],
['status', 'status'],
['refundStatus', 'refundStatus'],
['orderDateRange', 'orderDate', (v) => v?.[0]],
['orderDateRange', 'orderDateEnd', (v) => v?.[1]],
];
// ============================================================
// 下拉选项配置
// ============================================================
/** 订单状态选项(value 对应 API status: 不传=全部、1=支付成功,2=部分退款,3=全部退款,4=超时关闭) */
export const ORDER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '支付成功' },
{ value: '2', label: '部分退款' },
{ value: '3', label: '全部退款' },
{ value: '4', label: '超时关闭' },
] as const;
/** 退款状态选项(value 对应 API refundStatus: 不传=全部、0=退款失败,1=退款成功) */
export const REFUND_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '退款成功' },
{ value: '0', label: '退款失败' },
] as const;
+4 -4
View File
@@ -35,10 +35,10 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const orderList = '/manager/order/page';
const orderDetail = '/manager/order/info';
const orderPlayerList = '/manager/order/info/page';
const retryRefund = '/manager/order/retryRefund';
const orderList = '/admin/manager/order/page';
const orderDetail = '/admin/manager/order/info';
const orderPlayerList = '/admin/manager/order/info/userPage';
const retryRefund = '/admin/manager/order/retryRefund';
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
+102 -135
View File
@@ -1,33 +1,26 @@
import { computed, reactive, toRef, Ref, h } from 'vue';
import { message } from 'ant-design-vue';
import Big from 'big.js';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import {
getOrderList,
type TournamentAdminOrderPageVO,
type OrderListQueryParams,
import { useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import type {
TournamentAdminOrderPageVO,
OrderListQueryParams,
PageData,
ApiResult,
} from './services';
import { MOCK_ORDER_SUMMARY } from '@/config/mock/orderList';
import { getOrderList } from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
ORDER_STATUS_OPTIONS,
REFUND_STATUS_OPTIONS,
} from './config';
// ============================================================
// 常量
// ============================================================
/** 订单状态选项(value 对应 API status: 不传=全部、1=支付成功,2=部分退款,3=全部退款,4=超时关闭) */
export const ORDER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '支付成功' },
{ value: '2', label: '部分退款' },
{ value: '3', label: '全部退款' },
{ value: '4', label: '超时关闭' },
] as const;
/** 退款状态选项(value 对应 API refundStatus: 不传=全部、0=退款失败,1=退款成功) */
export const REFUND_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '退款成功' },
{ value: '0', label: '退款失败' },
] as const;
export { ORDER_STATUS_OPTIONS, REFUND_STATUS_OPTIONS };
/** 订单状态 StatusTag 映射 */
const ORDER_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
@@ -44,26 +37,15 @@ const REFUND_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }>
3: { label: '退款失败', tone: 'danger' },
};
// ============================================================
// Model
// ============================================================
/**
* 订单列表页数据模型
* 筛选字段名与 API query 参数(OrderListQueryParams)保持一致。
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*/
export function useOrderModel() {
// ===== 筛选条件(key 名对齐 API 查询参数) =====
const filterForm = reactive({
orderDateRange: null as [string, string] | null,
orderNo: '',
tName: '',
sName: '',
status: '',
refundStatus: '',
});
const filterForm = reactive({ ...FILTER_DEFAULTS });
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// 可搜索字段防抖
const { debouncedValue: debouncedOrderNo } = useDebounce(
toRef(filterForm, 'orderNo') as Ref<string>,
{ delay: 300 },
@@ -77,19 +59,77 @@ export function useOrderModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminOrderPageVO[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
const buildQueryParams = (): OrderListQueryParams => {
const { page, pageSize } = pagination.params.value;
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
return { page: String(page), limit: String(pageSize), ...params } as OrderListQueryParams;
};
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminOrderPageVO>>>(
() => getOrderList(buildQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const listData = computed<PageData<TournamentAdminOrderPageVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
// ===== 金额汇总(TODO: 汇总应来自独立接口) =====
const [summary, setSummary] = useState(MOCK_ORDER_SUMMARY);
const dataSource = computed(() => listData.value?.list || []);
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
const summary = computed(() => {
const list = listData.value?.list || [];
let totalOrder = new Big(0);
let totalPaid = new Big(0);
let totalRefund = new Big(0);
for (const item of list) {
totalOrder = totalOrder.plus(new Big(item.creatorSignupFee || 0));
totalPaid = totalPaid.plus(new Big(item.creatorSignupFeeActual || 0));
totalRefund = totalRefund.plus(new Big(item.creatorSignupFeeRefund || 0));
}
return {
totalOrderAmount: totalOrder.toNumber(),
totalPaidAmount: totalPaid.toNumber(),
totalRefundAmount: totalRefund.toNumber(),
};
});
const hasFilter = computed(() => {
const f = filterForm;
return (
!!f.orderDateRange ||
!!debouncedOrderNo.value?.trim() ||
!!debouncedTName.value?.trim() ||
!!debouncedSName.value?.trim() ||
!!f.status ||
!!f.refundStatus
);
});
// ===== 表格列配置(dataIndex 对齐 TournamentAdminOrderPageVO =====
const formatMoney = (text: string) => {
const n = parseFloat(text);
return isNaN(n) ? '0.00元' : `${n.toFixed(2)}`;
@@ -97,12 +137,7 @@ export function useOrderModel() {
const columns = [
{ title: '订单编号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{
title: '赛事名称',
dataIndex: 'name',
key: 'name',
width: 180,
},
{ title: '赛事名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '选手名称', dataIndex: 'realName', key: 'realName', width: 100 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '比赛组别', dataIndex: 'categoryName', key: 'categoryName', width: 120 },
@@ -176,88 +211,20 @@ export function useOrderModel() {
},
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): OrderListQueryParams => {
const params: OrderListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
const handleSearch = () => fetchList();
if (filterForm.orderDateRange) {
params.orderDate = filterForm.orderDateRange[0];
params.orderDateEnd = filterForm.orderDateRange[1];
}
if (filterForm.orderNo.trim()) {
params.orderNo = filterForm.orderNo.trim();
}
if (filterForm.tName.trim()) {
params.tName = filterForm.tName.trim();
}
if (filterForm.sName.trim()) {
params.sName = filterForm.sName.trim();
}
if (filterForm.status) {
params.status = filterForm.status;
}
if (filterForm.refundStatus) {
params.refundStatus = filterForm.refundStatus;
}
return params;
const handleReset = () => {
Object.assign(filterForm, FILTER_DEFAULTS);
pagination.reset();
setTimeout(fetchList, 350);
};
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
filterForm.orderDateRange !== null ||
debouncedOrderNo.value.trim() !== '' ||
debouncedTName.value.trim() !== '' ||
debouncedSName.value.trim() !== '' ||
filterForm.status !== '' ||
filterForm.refundStatus !== ''
);
});
// ===== 方法 =====
/** 查询 */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('订单列表查询参数:', queryParams);
const res = await getOrderList(queryParams);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (error: any) {
console.error('订单列表查询失败:', error);
} finally {
setLoading(false);
}
}, 500);
/** 重置 */
const handleReset = useThrottleFn(() => {
filterForm.orderDateRange = null;
filterForm.orderNo = '';
filterForm.tName = '';
filterForm.sName = '';
filterForm.status = '';
filterForm.refundStatus = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
// 等待 debounce 生效后自动查询
setTimeout(() => handleSearch(), 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
return {
+34 -61
View File
@@ -1,20 +1,18 @@
import { defineComponent, onMounted } from 'vue';
import {
Button,
Input,
Table,
Form,
Space,
Select,
Pagination,
Modal,
message,
} from 'ant-design-vue';
import { useUserModel, USER_STATUS_OPTIONS, ROLE_OPTIONS } from './model/useUserModel';
import { toggleUserActive } from './model/services';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Select, Pagination } from 'ant-design-vue';
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
import { useUserColumns } from './model/useUserColumns';
import { useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
/**
* 用户列表页面(纯视图层)
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:useUserModel.ts(状态 + 业务逻辑 + 交互处理)
* - 视图层:useUserColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
*/
export default defineComponent({
name: 'EventUsers',
setup() {
@@ -22,36 +20,33 @@ export default defineComponent({
filterForm,
loading,
dataSource,
columns,
pagination,
togglingId,
handleSearch,
handleReset,
handlePageChange,
handleToggleStatus,
} = useUserModel();
const { columns } = useUserColumns();
const { containerRef, height } = useContainerSize();
const handleToggleStatus = (record: any) => {
/** 渲染操作列 */
const renderAction = (record: any) => {
const isActive = record.status === '1';
const actionText = isActive ? '停用' : '启用';
Modal.confirm({
title: `${actionText}确认`,
content: `确认${actionText}用户"${record.realName}"吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
const res = await toggleUserActive({ userId: record.id, status: isActive ? '0' : '1' });
if (res.code == 200) {
message.success(`${actionText}`);
handleSearch();
} else message.error(res.msg || '操作失败');
} catch (e: any) {
console.error(`用户${actionText}失败:`, e);
}
},
});
return (
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleStatus(record)}
>
{isActive ? '停用' : '启用'}
</Button>
);
};
/** 完整表格列 */
const tableColumns = [
...columns,
{
@@ -63,10 +58,6 @@ export default defineComponent({
},
];
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
<div class={pageStyles.filter}>
@@ -81,16 +72,6 @@ export default defineComponent({
onPressEnter={handleSearch}
/>
</Form.Item>
<Form.Item label="角色" name="roleId">
<Select
value={filterForm.roleId}
options={ROLE_OPTIONS as any}
style={{ width: '140px' }}
allowClear
placeholder="全部"
onUpdate:value={(val: any) => (filterForm.roleId = val || '')}
/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
value={filterForm.status}
@@ -110,6 +91,7 @@ export default defineComponent({
</Form.Item>
</Form>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
@@ -122,16 +104,7 @@ export default defineComponent({
{{
bodyCell: (args: any) => {
if (args.column.key === 'action') {
const isActive = args.record.status === '1';
return (
<Button
type="link"
size="small"
onClick={() => handleToggleStatus(args.record)}
>
{isActive ? '停用' : '启用'}
</Button>
);
return renderAction(args.record);
}
},
}}
@@ -139,9 +112,9 @@ export default defineComponent({
</div>
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+53
View File
@@ -0,0 +1,53 @@
/**
* 用户列表页 - 配置文件
* 集中管理筛选默认值、字段映射、选项配置
*/
// ============================================================
// 筛选默认值
// ============================================================
export const FILTER_DEFAULTS = {
text: '',
roleId: '',
status: '',
} as const;
// ============================================================
// 字段映射配置
// 用于 buildQueryParams 函数,实现配置化参数转换
// ============================================================
type FieldMapping = [keyof typeof FILTER_DEFAULTS, string, ((v: any) => any)?];
export const FIELD_MAPPINGS: FieldMapping[] = [
['text', 'text', (v) => v?.trim?.() ?? v],
['roleId', 'roleId'],
['status', 'status'],
];
// ============================================================
// 选项配置
// ============================================================
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '停用' },
{ value: '1', label: '正常' },
] as const;
/** 角色选项(TODO: 对接角色 API) */
export const ROLE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '超级管理员' },
{ value: '2', label: '赛事管理员' },
{ value: '3', label: '裁判' },
{ value: '4', label: '财务' },
] as const;
// ============================================================
// 状态映射(用于 StatusTag 渲染)
// ============================================================
import type { StatusTagTone } from '@/components';
export const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '正常', tone: 'success' },
'0': { label: '停用', tone: 'danger' },
};
+5 -5
View File
@@ -30,11 +30,11 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const userList = '/sys/user/page';
const userSave = '/sys/user/save';
const userUpdate = '/sys/user/update';
const userActive = '/sys/user/active';
const userUpdatePwd = '/sys/user/updatepwd';
const userList = '/admin/manager/user/page';
const userSave = '/admin/sys/user/save';
const userUpdate = '/admin/sys/user/update';
const userActive = '/admin/manager/user/active';
const userUpdatePwd = '/admin/sys/user/updatepwd';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -0,0 +1,107 @@
import { h, type VNodeChild } from 'vue';
import { Image, Space } from 'ant-design-vue';
import type { ImageProps } from 'ant-design-vue/es/image';
import { StatusTag } from '@/components';
import { STATUS_MAP } from './config';
// 性别映射
const GENDER_MAP: Record<string, string> = {
'1': '男',
'2': '女',
};
/**
* 用户列表页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 操作列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
export function useUserColumns() {
const columns = [
{
title: '用户昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'center' as const,
},
{
title: '用户姓名',
dataIndex: 'realName',
key: 'realName',
width: 110,
align: 'center' as const,
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 80,
align: 'center' as const,
customRender: ({ text }: { text: string }) => GENDER_MAP[text] || '',
},
{
title: '证件号',
dataIndex: 'idCard',
key: 'idCard',
width: 120,
align: 'center' as const,
},
{
title: '证件图片',
dataIndex: 'idCardImgList',
key: 'idCardImgList',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string[] }): VNodeChild => {
const imgList = Array.isArray(text) ? text : [];
if (imgList.length === 0) return '-';
return h(
Space,
{ size: 4 },
{
default: () =>
imgList.map((url, index) =>
h<ImageProps>(Image, {
key: index,
src: url,
alt: `证件图${index + 1}`,
width: 40,
height: 40,
style: { objectFit: 'cover', borderRadius: '4px', cursor: 'pointer' },
preview: true,
}),
),
},
);
},
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
align: 'center' as const,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
customRender: ({ text }: { text: string }) => {
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
];
return { columns };
}
+135 -92
View File
@@ -1,125 +1,168 @@
import { computed, reactive, toRef, Ref, h } from 'vue';
import { message } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { getUserList, type TournamentAdminUserVO, type UserListQueryParams } from './services';
import { computed, reactive, toRef, Ref, type UnwrapRef } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { useState, useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, type UsePaginationReturn } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import {
getUserList,
toggleUserActive,
type TournamentAdminUserVO,
type UserListQueryParams,
type PageData,
type ApiResult,
} from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
USER_STATUS_OPTIONS,
ROLE_OPTIONS,
STATUS_MAP,
} from './config';
// ============================================================
// 常量
// ============================================================
export { USER_STATUS_OPTIONS, ROLE_OPTIONS, STATUS_MAP };
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '停用' },
{ value: '1', label: '正常' },
] as const;
/** 角色选项(TODO: 对接角色 API) */
export const ROLE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '超级管理员' },
{ value: '2', label: '赛事管理员' },
{ value: '3', label: '裁判' },
{ value: '4', label: '财务' },
] as const;
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '正常', tone: 'success' },
'0': { label: '停用', tone: 'danger' },
};
// ============================================================
// Model
// ============================================================
// 提取 Pagination Ref 类型
type PaginationRef = UnwrapRef<UsePaginationReturn>;
type PageSizeRef = PaginationRef['pageSize'];
/**
* 用户列表页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useUserColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useUserModel() {
const filterForm = reactive({
text: '',
roleId: '',
status: '',
});
// ===== 筛选表单状态 =====
const filterForm = reactive({ ...FILTER_DEFAULTS });
// ===== 弹窗状态 =====
const [togglingId, setTogglingId] = useState('');
// ===== 分页管理 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 防抖处理 =====
const { debouncedValue: debouncedText } = useDebounce(toRef(filterForm, 'text') as Ref<string>, {
delay: 300,
});
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminUserVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const columns = [
{ title: '用户姓名', dataIndex: 'realName', key: 'realName', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
{ title: '角色', dataIndex: 'roleName', key: 'roleName', width: 120 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 170 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
customRender: ({ text }: { text: string }) => {
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
];
// ===== 构建查询参数 =====
const buildQueryParams = (): UserListQueryParams => {
const params: UserListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.text.trim()) params.text = filterForm.text.trim();
if (filterForm.roleId) params.roleId = filterForm.roleId;
if (filterForm.status) params.status = filterForm.status;
return params;
const { page, pageSize } = pagination.params.value;
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
return { page: String(page), limit: String(pageSize), ...params } as UserListQueryParams;
};
const hasFilter = computed(
() => debouncedText.value.trim() !== '' || filterForm.roleId !== '' || filterForm.status !== '',
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminUserVO>>>(
() => getUserList(buildQueryParams()),
{
refreshDeps: [],
formatResult: (res) => res,
},
);
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const params = buildQueryParams();
console.log('用户列表查询参数:', params);
const res = await getUserList(params);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else message.error(res.msg || '查询失败');
} catch (e: any) {
console.error('用户列表查询失败:', e);
} finally {
setLoading(false);
}
}, 500);
// 提取列表数据
const listData = computed<PageData<TournamentAdminUserVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
const handleReset = useThrottleFn(() => {
const dataSource = computed(() => listData.value?.list || []);
// 同步分页总条数
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 计算属性 =====
const hasFilter = computed(() => {
const f = filterForm;
return !!debouncedText.value?.trim() || !!f.roleId || !!f.status;
});
// ===== 事件处理 =====
const handleSearch = () => fetchList();
const handleReset = () => {
filterForm.text = '';
filterForm.roleId = '';
filterForm.status = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
}, 500);
pagination.reset();
setTimeout(() => fetchList(), 0);
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination.pageSize as PageSizeRef)) {
pagination.setPageSize(pageSize);
}
fetchList();
};
// ===== 启用/停用用户 =====
const handleToggleStatus = async (record: TournamentAdminUserVO) => {
const isActive = record.status === '1';
const actionText = isActive ? '停用' : '启用';
Modal.confirm({
title: `${actionText}确认`,
content: `确认${actionText}用户"${record.realName}"吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
setTogglingId(record.id);
try {
const res = await toggleUserActive({ userId: record.id, status: isActive ? '0' : '1' });
if (res.code == 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
message.error(res.msg || '操作失败');
}
} catch (e: any) {
console.error(`用户${actionText}失败:`, e);
message.error(`用户${actionText}失败`);
} finally {
setTogglingId('');
}
},
});
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
togglingId,
hasFilter,
handleSearch,
handleReset,
handlePageChange,
handleToggleStatus,
};
}
+17 -19
View File
@@ -5,10 +5,11 @@ import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { auth } from '@/hooks/useAuth';
import { useEffect, useState } from '@/hooks';
import { useUserStore } from '@/stores/userStore';
import { login as loginApi } from '@/api/login';
import styles from './index.module.less';
interface LoginForm {
username: string;
phone: string;
password: string;
}
@@ -18,9 +19,10 @@ export default defineComponent({
const route = useRoute();
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const { setUser } = useUserStore();
const form = reactive<LoginForm>({
username: '',
phone: '',
password: '',
});
@@ -29,7 +31,7 @@ export default defineComponent({
if (saved) {
try {
const data = JSON.parse(saved);
form.username = data.username || '';
form.phone = data.phone || '';
form.password = data.password || '';
setRememberMe(true);
} catch {
@@ -44,7 +46,7 @@ export default defineComponent({
};
const handleSubmit = async () => {
if (!form.username || !form.password) {
if (!form.phone || !form.password) {
message.warning('请输入用户名和密码');
return;
}
@@ -52,30 +54,26 @@ export default defineComponent({
setLoading(true);
try {
await new Promise((r) => setTimeout(r, 600));
const token = 'mock_token_' + Date.now();
// 写入账号信息到 userStore
const { setUser } = useUserStore();
setUser({ phone: form.username, nickname: form.username, token });
const redirect = Array.isArray(route.query.redirect)
? route.query.redirect[0]
: route.query.redirect;
await auth.login(token, redirect || undefined);
message.success('登录成功');
const res = await loginApi({ phone: form.phone, password: form.password });
setUser({ phone: form.phone, nickname: res.data.nickname, token: res.data.token });
if (rememberMe.value) {
localStorage.setItem(
'cpms_login_remember',
JSON.stringify({
username: form.username,
phone: form.phone,
password: form.password,
}),
);
} else {
localStorage.removeItem('cpms_login_remember');
}
const redirect = Array.isArray(route.query.redirect)
? route.query.redirect[0]
: route.query.redirect;
await auth.login(res.data.token, redirect || undefined);
message.success('登录成功');
} catch (err) {
message.error('登录失败,请重试');
} finally {
@@ -211,9 +209,9 @@ export default defineComponent({
layout="vertical"
onFinish={handleSubmit}
>
<FormItem name="username">
<FormItem name="phone">
<Input
v-model={[form.username, 'value']}
v-model={[form.phone, 'value']}
placeholder="请输入用户名"
size="large"
v-slots={{ prefix: () => <UserOutlined /> }}
+41 -16
View File
@@ -3,8 +3,7 @@
* 该页面使用的所有接口集中管理
*/
import { get, post } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockUserListPage } from '@/config/mock/userList';
import { fetchPermissions } from '@/api/menu';
import type {
UserListQueryParams,
UserSaveParams,
@@ -30,40 +29,66 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const userList = '/sys/user/page';
const userSave = '/sys/user/save';
const userUpdate = '/sys/user/update';
const userActive = '/sys/user/active';
const userUpdatePwd = '/sys/user/updatepwd';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
// ============================================================
// API 函数
// ============================================================
/** 用户分页列表 */
/**
* 用户列表 - 通过 fetchPermissions 接口获取用户权限列表
* 接口: GET /admin/sys/role/permissionList
* 返回权限编码列表,前端做分页处理
*/
export async function getUserList(
params: UserListQueryParams,
_params: UserListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminUserVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockUserListPage(page, limit) };
}
return get(userList, params as Record<string, any>);
// 调用 fetchPermissions 获取用户权限数据
const list = await fetchPermissions();
// 将权限编码字符串转为类用户列表格式,支持分页
const page = Number(_params.page) || 1;
const limit = Number(_params.limit) || 10;
const start = (page - 1) * limit;
const end = start + limit;
const pagedList = list.slice(start, end);
// 将权限编码映射为用户 VO 格式(后端返回的权限码可能包含用户信息)
const userListData: TournamentAdminUserVO[] = pagedList.map((item, index) => {
// 如果 item 是对象则直接使用,如果是字符串则构造基本结构
if (typeof item === 'object' && item !== null) {
return item as unknown as TournamentAdminUserVO;
}
return {
id: String(start + index + 1),
realName: String(item),
phone: '',
roleName: '',
status: '1',
createTime: '',
} as TournamentAdminUserVO;
});
return {
code: 200,
msg: 'success',
data: {
total: list.length,
list: userListData,
},
};
}
/** 新增用户 */
export function saveUser(params: UserSaveParams): Promise<ApiResult<Record<string, never>>> {
if (USE_MOCK) return delay(MOCK_DELAY).then(() => ({ code: 200, msg: 'success', data: {} }));
return post(userSave, params);
}
/** 更新用户 */
export function updateUser(params: UserUpdateParams): Promise<ApiResult<Record<string, never>>> {
if (USE_MOCK) return delay(MOCK_DELAY).then(() => ({ code: 200, msg: 'success', data: {} }));
return post(userUpdate, params);
}
+5 -3
View File
@@ -245,9 +245,11 @@ function ensureLayoutRoute() {
* 无论是后端数据还是兜底数据,都走同一套 logic
*/
function applyMenuTree(menuTree: MenuNode[]) {
state.menuTree = menuTree;
state.menuItems = transformMenuNode(menuTree);
state.homePath = getFirstMenuPath(menuTree);
// 防御:确保是数组,避免后端返回 null/undefined 导致 \"nodes is not iterable\"
const safeMenuTree = Array.isArray(menuTree) ? menuTree : [];
state.menuTree = safeMenuTree;
state.menuItems = transformMenuNode(safeMenuTree);
state.homePath = getFirstMenuPath(safeMenuTree);
ensureLayoutRoute();
+28 -5
View File
@@ -1,33 +1,38 @@
import { reactive, computed, toRefs } from 'vue';
import type { PermissionCode } from '@/types';
import { fetchPermissions } from '@/api/menu';
import { fetchAuthData } from '@/api/menu';
interface PermissionState {
/** 权限编码集合 */
codes: Set<PermissionCode>;
/** 角色列表 */
roles: string[];
/** 是否已加载 */
loaded: boolean;
}
const state = reactive<PermissionState>({
codes: new Set(),
roles: [],
loaded: false,
});
export function usePermissionStore() {
/**
* 从后端加载权限编码
* 从后端加载权限编码和角色列表
*/
const loadPermissions = async () => {
if (state.loaded) return;
try {
const codes = await fetchPermissions();
state.codes = new Set(codes);
const { roleList, permsList } = await fetchAuthData();
state.codes = new Set(permsList);
state.roles = roleList;
state.loaded = true;
} catch (err) {
console.error('加载权限失败:', err);
state.codes = new Set();
state.roles = [];
state.loaded = true;
}
};
@@ -37,12 +42,13 @@ export function usePermissionStore() {
*/
const clearPermissions = () => {
state.codes = new Set();
state.roles = [];
state.loaded = false;
};
/**
* 判断是否拥有某个权限
* @param code 权限编码,如 'user:delete'
* @param code 权限编码,如 'events.list.view'
*/
const hasPermission = (code: PermissionCode): boolean => {
return state.codes.has(code);
@@ -55,6 +61,21 @@ export function usePermissionStore() {
return codes.some((code) => state.codes.has(code));
};
/**
* 判断是否拥有某个角色
* @param role 角色标识,如 'admin'、'coo'
*/
const hasRole = (role: string): boolean => {
return state.roles.includes(role);
};
/**
* 判断是否拥有任意一个角色
*/
const hasAnyRole = (roles: string[]): boolean => {
return roles.some((role) => state.roles.includes(role));
};
return {
...toRefs(state),
loaded: computed(() => state.loaded),
@@ -62,5 +83,7 @@ export function usePermissionStore() {
clearPermissions,
hasPermission,
hasAnyPermission,
hasRole,
hasAnyRole,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ interface UserState {
}
const state = reactive<UserState>({
phone: '13377981125',
phone: '',
nickname: '',
token: '',
});
+2
View File
@@ -72,6 +72,8 @@ export interface MenuNode {
* 后端下发此字段后路由守卫会自动写入 cachedViews
*/
componentName?: string;
/** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */
permission?: string;
/** 子菜单/子路由 */
children?: MenuNode[];
}
+29
View File
@@ -2,6 +2,35 @@
* 通用工具函数
*/
/**
* 判断值是否有意义(非空、非空字符串、非空数组)
*/
export function hasValue(v: unknown): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.length > 0 && v.some((item) => item != null);
if (typeof v === 'string') return v.trim() !== '';
return true;
}
/**
* 判断值是否为空(null、undefined、空字符串)
*/
export function isEmptyValue(v: unknown): boolean {
return v == null || (typeof v === 'string' && v.trim() === '');
}
/**
* 安全转换值(带异常处理和默认值)
*/
export function safeTransform<T>(value: T, transform?: (v: T) => unknown): T | unknown {
if (!transform) return value;
try {
return transform(value) ?? value;
} catch {
return value;
}
}
/**
* 生成唯一 ID
*/