Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49263792bc | |||
| 0b06cdca69 | |||
| 3330c38092 | |||
| ce80c6a839 | |||
| 9d98ebfc38 | |||
| 6d72c29a33 | |||
| a45bdf8c4e | |||
| 7661c5c45b |
Generated
+21
@@ -9,8 +9,10 @@
|
||||
"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",
|
||||
@@ -1050,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,
|
||||
@@ -1677,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+93
-4
@@ -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('/op/permission');
|
||||
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('/admin/sys/role/permissionList');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -61,46 +61,50 @@ export default defineComponent({
|
||||
wrapClassName={styles.eventRegulationModalMain}
|
||||
>
|
||||
<Spin spinning={loading.value}>
|
||||
{ruleData.value ? (
|
||||
<>
|
||||
{/* 规程内容 */}
|
||||
{ruleData.value.rulesDescr && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程内容</div>
|
||||
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 规程图片 */}
|
||||
{(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.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 && (
|
||||
{/* 无数据提示 */}
|
||||
{!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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading.value && (
|
||||
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
|
||||
暂无规程信息
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -193,23 +194,3 @@ export function useEventListModel() {
|
||||
handleToggleShelf,
|
||||
};
|
||||
}
|
||||
|
||||
function hasValue(v: any): 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;
|
||||
}
|
||||
|
||||
function isEmptyValue(v: any): boolean {
|
||||
return v == null || (typeof v === 'string' && v.trim() === '');
|
||||
}
|
||||
|
||||
function safeTransform(value: any, transform?: (v: any) => any): any {
|
||||
if (!transform) return value;
|
||||
try {
|
||||
return transform(value) ?? value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.ts(API 调用)
|
||||
* - 逻辑层: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}
|
||||
|
||||
@@ -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=PC,2=小程序) */
|
||||
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': '小程序',
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export type {
|
||||
// ============================================================
|
||||
// URL 常量
|
||||
// ============================================================
|
||||
const operationLogs = '/tournament/operation/page';
|
||||
const operationLogs = '/admin/manager/operation/page';
|
||||
|
||||
// ============================================================
|
||||
// API 函数
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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=PC,2=小程序) */
|
||||
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.ts(API 调用)
|
||||
* - 逻辑层:本文件(状态 + 业务逻辑)
|
||||
* - 视图层: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>
|
||||
|
||||
@@ -80,6 +80,8 @@ function renderBodyCell({
|
||||
}
|
||||
|
||||
if (column.key === 'action') {
|
||||
// 退款状态:1=无退款,2=退款成功,3=退款失败,-1=其他
|
||||
// 只有退款失败(3)时显示重新退款按钮
|
||||
const showReRefund = record.refundStatus === 3;
|
||||
return (
|
||||
<Space>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||
import Big from 'big.js';
|
||||
import { StatusTag, type StatusTagTone } from '@/components';
|
||||
import { useState, useDebounce } from '@/hooks';
|
||||
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,
|
||||
@@ -76,7 +78,6 @@ export function useOrderModel() {
|
||||
return { page: String(page), limit: String(pageSize), ...params } as OrderListQueryParams;
|
||||
};
|
||||
|
||||
// ===== 列表数据请求 =====
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
@@ -86,7 +87,6 @@ export function useOrderModel() {
|
||||
{ refreshDeps: [], formatResult: (res) => res },
|
||||
);
|
||||
|
||||
// 提取列表数据
|
||||
const listData = computed<PageData<TournamentAdminOrderPageVO> | undefined>(() => {
|
||||
const res = data.value;
|
||||
return res ? (res as any).data : undefined;
|
||||
@@ -94,20 +94,30 @@ export function useOrderModel() {
|
||||
|
||||
const dataSource = computed(() => listData.value?.list || []);
|
||||
|
||||
// 同步分页总条数
|
||||
useEffect(() => {
|
||||
const total = (data.value as any)?.data?.total;
|
||||
if (total !== undefined) pagination.setTotal(total);
|
||||
}, [data]);
|
||||
|
||||
// ===== 金额汇总(TODO: 汇总应来自独立接口) =====
|
||||
const [summary] = useState({
|
||||
totalOrderAmount: 0,
|
||||
totalPaidAmount: 0,
|
||||
totalRefundAmount: 0,
|
||||
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 (
|
||||
@@ -120,7 +130,6 @@ export function useOrderModel() {
|
||||
);
|
||||
});
|
||||
|
||||
// ===== 表格列配置 =====
|
||||
const formatMoney = (text: string) => {
|
||||
const n = parseFloat(text);
|
||||
return isNaN(n) ? '0.00元' : `${n.toFixed(2)}元`;
|
||||
@@ -202,7 +211,6 @@ export function useOrderModel() {
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 列表操作 =====
|
||||
const handleSearch = () => fetchList();
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -220,7 +228,6 @@ export function useOrderModel() {
|
||||
};
|
||||
|
||||
return {
|
||||
// 数据
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
@@ -228,31 +235,8 @@ export function useOrderModel() {
|
||||
summary,
|
||||
pagination,
|
||||
hasFilter,
|
||||
// 方法
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
function hasValue(v: any): 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;
|
||||
}
|
||||
|
||||
function isEmptyValue(v: any): boolean {
|
||||
return v == null || (typeof v === 'string' && v.trim() === '');
|
||||
}
|
||||
|
||||
function safeTransform(value: any, transform?: (v: any) => any): any {
|
||||
if (!transform) return value;
|
||||
try {
|
||||
return transform(value) ?? value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.ts(API 调用)
|
||||
* - 逻辑层: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}
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.ts(API 调用)
|
||||
* - 逻辑层:本文件(状态 + 业务逻辑)
|
||||
* - 视图层: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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MenuNode {
|
||||
* 后端下发此字段后路由守卫会自动写入 cachedViews
|
||||
*/
|
||||
componentName?: string;
|
||||
/** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */
|
||||
permission?: string;
|
||||
/** 子菜单/子路由 */
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user