feat: 优化路由 搭建赛事管理页面基础 处理部分样式问题 完成赛事列表页面搭建
This commit is contained in:
parent
5523695518
commit
03f1b6c220
|
|
@ -1,14 +1,17 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="六个羽友赛事运营平台" />
|
<meta name="description" content="六个羽友赛事运营平台" />
|
||||||
<title>六个羽友赛事运营平台</title>
|
<title>六羽赛事运营平台</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vite",
|
"dev": "vite",
|
||||||
"build:dev": "vue-tsc --noEmit && vite build --mode development",
|
"build:dev": "vue-tsc --noEmit && vite build --mode development",
|
||||||
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
||||||
"build:prod": "vue-tsc --noEmit && vite build --mode production",
|
"build:prod": "vue-tsc --noEmit && vite build --mode production",
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"ant-design-vue": "^4.0.0",
|
"ant-design-vue": "^4.0.0",
|
||||||
|
"china-area-data": "^5.0.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,11 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { ConfigProvider } from 'ant-design-vue';
|
import { ConfigProvider } from 'ant-design-vue';
|
||||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
import zhCN from 'ant-design-vue/locale/zh_CN';
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根组件
|
* 根组件
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,43 @@ a {
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 页面布局公共类 =====
|
||||||
|
|
||||||
|
// 页面容器:纵向 flex,占满高度;筛选区与表格区作为直接子元素
|
||||||
|
.page-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面筛选区域:横向 flex,可换行
|
||||||
|
.page-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
row-gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选区域操作按钮组:横向 flex
|
||||||
|
.page-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格显示区域:纵向 flex,占据剩余高度。
|
||||||
|
// overflow: hidden 防止子元素撑开容器,滚动由 Table 的 scroll.x/y 接管。
|
||||||
|
// 分页组件作为独立子元素置于 Table 下方,不占用 Table 的 scroll 空间。
|
||||||
|
.page-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #1677ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.page-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
row-gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.page-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.page-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type { MenuNode } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ====== 兜底路由配置 ======
|
||||||
|
*
|
||||||
|
* 当后端菜单接口不可用时,使用此配置作为降级方案。
|
||||||
|
* 数据格式与后端 `/menu/tree` 接口返回完全一致,
|
||||||
|
* 因此可以直接通过 transformMenuToRoutes / transformMenuNode 复用同一套处理逻辑。
|
||||||
|
*
|
||||||
|
* ## 后端应返回的数组格式示例
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* [
|
||||||
|
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "DashboardOutlined" },
|
||||||
|
* {
|
||||||
|
* "id": "events", "name": "赛事管理", "path": "events", "icon": "TrophyOutlined",
|
||||||
|
* "children": [
|
||||||
|
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list" },
|
||||||
|
* { "id": "events_orders", "name": "订单管理", "path": "events/orders", "component": "events/orders" }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### 字段说明
|
||||||
|
* | 字段 | 类型 | 必填 | 说明 |
|
||||||
|
* |------|------|------|------|
|
||||||
|
* | id | number\|string | 是 | 唯一标识 |
|
||||||
|
* | name | string | 是 | 菜单名称/路由 name |
|
||||||
|
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
||||||
|
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
||||||
|
* | icon | string | 否 | antd 图标名(如 DashboardOutlined) |
|
||||||
|
* | sort | number | 否 | 排序权重(越小越前) |
|
||||||
|
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
||||||
|
* | externalLink | string | 否 | 外链地址 |
|
||||||
|
* | activeMenu | string | 否 | 高亮菜单路径 |
|
||||||
|
* | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) |
|
||||||
|
* | children | MenuNode[] | 否 | 子路由 |
|
||||||
|
*
|
||||||
|
* ### 关于 routes → menuItems 的转换
|
||||||
|
* `menuStore.ts` 中的 `transformMenuNode()` 已完成此功能,
|
||||||
|
* 它将 MenuNode[] 递归转换为 antd Menu 组件所需的 MenuItemRaw[] 格式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FALLBACK_MENU_NODES: MenuNode[] = [
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
name: '赛事管理',
|
||||||
|
path: 'events',
|
||||||
|
icon: 'TrophyOutlined',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'events_list',
|
||||||
|
name: '赛事列表',
|
||||||
|
path: 'events/list',
|
||||||
|
component: 'events/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events_orders',
|
||||||
|
name: '订单管理',
|
||||||
|
path: 'events/orders',
|
||||||
|
component: 'events/orders',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events_users',
|
||||||
|
name: '用户列表',
|
||||||
|
path: 'events/users',
|
||||||
|
component: 'events/users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events_banner',
|
||||||
|
name: 'Banner配置',
|
||||||
|
path: 'events/banner',
|
||||||
|
component: 'events/banner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events_logs',
|
||||||
|
name: '赛事操作日志',
|
||||||
|
path: 'events/logs',
|
||||||
|
component: 'events/logs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -7,4 +7,6 @@ export * from './useAuth';
|
||||||
export * from './useWebSocket';
|
export * from './useWebSocket';
|
||||||
export * from './useBasicLayout';
|
export * from './useBasicLayout';
|
||||||
export * from './useBreadcrumb';
|
export * from './useBreadcrumb';
|
||||||
|
export * from './useThrottleFn';
|
||||||
|
export * from './useContainerSize';
|
||||||
export * from './useContext';
|
export * from './useContext';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听容器尺寸变化,返回动态 width/height 给 Table 的 scroll 属性使用。
|
||||||
|
*
|
||||||
|
* 基于 ResizeObserver,窗口缩放 / 侧边栏收起展开 / 筛选区折叠 等任何导致
|
||||||
|
* 容器尺寸变化的行为都会自动触发重新计算,无需额外处理。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ```ts
|
||||||
|
* const { containerRef, width, height } = useContainerSize();
|
||||||
|
* // <div ref={containerRef}>
|
||||||
|
* // <Table scroll={{ x: width.value, y: height.value }} />
|
||||||
|
* // </div>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param headerOffset 从容器高度中扣除的表头偏移量,默认 45
|
||||||
|
*/
|
||||||
|
export function useContainerSize(options?: { headerOffset?: number }) {
|
||||||
|
const { headerOffset = 45 } = options ?? {};
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null) as Ref<HTMLElement | null>;
|
||||||
|
const width = ref(0);
|
||||||
|
const height = ref(0);
|
||||||
|
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const updateSize = () => {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
width.value = el.clientWidth;
|
||||||
|
height.value = Math.max(el.clientHeight - headerOffset, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateSize();
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const w = entry.contentRect.width;
|
||||||
|
const h = entry.contentRect.height;
|
||||||
|
if (w !== width.value) width.value = w;
|
||||||
|
if (h !== height.value) height.value = Math.max(h - headerOffset, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => observer?.disconnect());
|
||||||
|
|
||||||
|
return { containerRef, width, height };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// src/hooks/useDebounce.ts
|
// src/hooks/useDebounce.ts
|
||||||
import { watch, onUnmounted } from 'vue';
|
import { watch, onUnmounted, Ref } from 'vue';
|
||||||
import { useState } from './useState';
|
import { useState } from './useState';
|
||||||
|
|
||||||
export interface UseDebounceOptions {
|
export interface UseDebounceOptions {
|
||||||
|
|
@ -8,14 +8,11 @@ export interface UseDebounceOptions {
|
||||||
maxWait?: number;
|
maxWait?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDebounce<T>(
|
export function useDebounce<T>(source: Ref<T>, options: number | UseDebounceOptions = {}) {
|
||||||
source: any, // Ref<T>
|
|
||||||
options: number | UseDebounceOptions = {},
|
|
||||||
) {
|
|
||||||
const config = typeof options === 'number' ? { delay: options } : options;
|
const config = typeof options === 'number' ? { delay: options } : options;
|
||||||
const { delay = 300, immediate = false, maxWait } = config;
|
const { delay = 300, immediate = false, maxWait } = config;
|
||||||
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
|
const [debouncedValue, setDebouncedValue] = useState<T>(source.value as T);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
|
||||||
let timer: any = null;
|
let timer: any = null;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数封装
|
||||||
|
* @param fn 需要节流的函数
|
||||||
|
* @param delay 节流间隔时间(ms),默认 500
|
||||||
|
* @returns 返回节流后的函数,连续调用时只会在每个 delay 周期内执行一次
|
||||||
|
*/
|
||||||
|
export function useThrottleFn<T extends (...args: any[]) => any>(
|
||||||
|
fn: T,
|
||||||
|
delay: number = 500,
|
||||||
|
): T & { cancel: () => void } {
|
||||||
|
const lastTime = ref<number>(0);
|
||||||
|
let timer: any = null;
|
||||||
|
|
||||||
|
const throttled = function (this: any, ...args: any[]) {
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = delay - (now - lastTime.value);
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// 冷却时间已过,立即执行
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
lastTime.value = now;
|
||||||
|
fn.apply(this, args);
|
||||||
|
} else if (!timer) {
|
||||||
|
// 冷却时间未过,延后执行
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
lastTime.value = Date.now();
|
||||||
|
timer = null;
|
||||||
|
fn.apply(this, args);
|
||||||
|
}, remaining);
|
||||||
|
}
|
||||||
|
} as T & { cancel: () => void };
|
||||||
|
|
||||||
|
throttled.cancel = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return throttled;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
.container {
|
.container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
height: 100vh;
|
||||||
|
|
||||||
.sider {
|
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|
@ -12,8 +9,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #fff;
|
color: #1a1a1a;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoText {
|
.logoText {
|
||||||
|
|
@ -22,14 +19,6 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
|
||||||
background: #fff;
|
|
||||||
padding: 0 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger {
|
.trigger {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -48,10 +37,16 @@
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 280px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.main {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.logoText {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.trigger:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { computed, defineComponent, h, ref } from 'vue';
|
import { computed, defineComponent, h, ref } from 'vue';
|
||||||
|
import type { CSSProperties } from 'vue';
|
||||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||||
import { Layout, Menu } from 'ant-design-vue';
|
import { Layout, Menu } from 'ant-design-vue';
|
||||||
import type { MenuProps } from 'ant-design-vue';
|
import type { MenuProps } from 'ant-design-vue';
|
||||||
|
|
@ -8,12 +9,34 @@ import {
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import styles from './BasicLayout.module.less';
|
import styles from './BasicLayout.module.less';
|
||||||
|
|
||||||
const { Header, Sider, Content, Footer } = Layout;
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
const headerStyle: CSSProperties = {
|
||||||
|
background: '#fff',
|
||||||
|
padding: '0 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
boxShadow: '0 1px 4px rgba(0, 21, 41, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const siderStyle: CSSProperties = {
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '2px 0 8px rgba(0, 21, 41, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerStyle: CSSProperties = {
|
||||||
|
padding: '0 50px 16px 50px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#3a3a3a',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图标名 → 组件映射
|
* 图标名 → 组件映射
|
||||||
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
||||||
|
|
@ -23,6 +46,7 @@ const ICON_MAP: Record<string, any> = {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,12 +99,12 @@ export default defineComponent({
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<Layout class={styles.container}>
|
<Layout class={styles.container}>
|
||||||
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
|
<Sider style={siderStyle} collapsed={collapsed.value} trigger={null} collapsible>
|
||||||
<div class={styles.logo}>
|
<div class={styles.logo}>
|
||||||
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六个羽友赛事'}</span>
|
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六羽赛事运营平台'}</span>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="light"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={selectedKeys.value}
|
selectedKeys={selectedKeys.value}
|
||||||
items={resolvedItems.value}
|
items={resolvedItems.value}
|
||||||
|
|
@ -88,8 +112,8 @@ export default defineComponent({
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
<Layout>
|
<Layout class={styles.main}>
|
||||||
<Header class={styles.header}>
|
<Header style={headerStyle}>
|
||||||
<span class={styles.trigger} onClick={toggleCollapsed}>
|
<span class={styles.trigger} onClick={toggleCollapsed}>
|
||||||
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -99,7 +123,7 @@ export default defineComponent({
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
<Footer class={styles.footer}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
.container {
|
||||||
|
// 容器
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Button, Input, Table, Modal, Space, Popconfirm, Form } from 'ant-design-vue';
|
||||||
|
import { useBannerModel } from './model/useBannerModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner 配置
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventBanner',
|
||||||
|
setup() {
|
||||||
|
const {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
modalVisible,
|
||||||
|
editingRecord,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
handleAdd,
|
||||||
|
handleEdit,
|
||||||
|
handleCloseModal,
|
||||||
|
handleDelete,
|
||||||
|
} = useBannerModel();
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-filter">
|
||||||
|
<Form layout="inline" model={filterForm}>
|
||||||
|
<Form.Item label="Banner 标题" name="searchTitle">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入 Banner 标题"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-table">
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<Button type="primary" onClick={handleAdd}>
|
||||||
|
新增 Banner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
...columns,
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
customRender: ({ record }: { record: any }) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => handleEdit(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确定删除该 Banner?" onConfirm={() => handleDelete(record)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={dataSource.value}
|
||||||
|
loading={loading.value}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
total: pagination.value.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`,
|
||||||
|
onChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingRecord.value ? '编辑 Banner' : '新增 Banner'}
|
||||||
|
visible={modalVisible.value}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{editingRecord.value
|
||||||
|
? `正在编辑: ${editingRecord.value.title}`
|
||||||
|
: '在此新增 Banner 配置'}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { computed, reactive, toRef, Ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner 配置页数据模型
|
||||||
|
*/
|
||||||
|
export function useBannerModel() {
|
||||||
|
// ===== 筛选条件(合并在一个 reactive 对象中)=====
|
||||||
|
const filterForm = reactive({
|
||||||
|
searchTitle: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 防抖 300ms
|
||||||
|
const { debouncedValue: debouncedTitle } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchTitle') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 表格状态 =====
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [dataSource, setDataSource] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 弹窗状态 =====
|
||||||
|
const [modalVisible, setModalVisible] = useState<boolean>(false);
|
||||||
|
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||||
|
|
||||||
|
// ===== 表格列配置 =====
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Banner 标题', dataIndex: 'title', key: 'title' },
|
||||||
|
{ title: '排序值', dataIndex: 'sort', key: 'sort' },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||||
|
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime' },
|
||||||
|
{ title: '更新时间', dataIndex: 'updateTime', key: 'updateTime' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 计算属性 =====
|
||||||
|
const hasFilter = computed(() => {
|
||||||
|
return debouncedTitle.value.trim() !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 方法 =====
|
||||||
|
|
||||||
|
/** 查询(节流 500ms) */
|
||||||
|
const handleSearch = useThrottleFn(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
console.log('搜索条件:', { title: debouncedTitle.value });
|
||||||
|
message.success('查询成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.msg || '查询失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
/** 重置(节流 500ms) */
|
||||||
|
const handleReset = useThrottleFn(() => {
|
||||||
|
filterForm.searchTitle = '';
|
||||||
|
setPagination({ current: 1, pageSize: 10, total: 0 });
|
||||||
|
setDataSource([]);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setPagination({ ...pagination.value, current: page, pageSize });
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 打开新增弹窗 */
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingRecord(null);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 打开编辑弹窗 */
|
||||||
|
const handleEdit = (record: any) => {
|
||||||
|
setEditingRecord(record);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除(节流 500ms) */
|
||||||
|
const handleDelete = useThrottleFn(async (record: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
message.success('删除成功');
|
||||||
|
handleSearch();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.msg || '删除失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
hasFilter,
|
||||||
|
modalVisible,
|
||||||
|
setModalVisible,
|
||||||
|
editingRecord,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
handleAdd,
|
||||||
|
handleEdit,
|
||||||
|
handleCloseModal,
|
||||||
|
handleDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 3px solid #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textBlock {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descriptions 列宽稍微压缩一些,避免单标签被挤
|
||||||
|
.desc :global(.ant-descriptions-item-label) {
|
||||||
|
width: 110px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组别信息下的子表格
|
||||||
|
.group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组信息列表
|
||||||
|
.divisionList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divisionItem {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Modal, Descriptions, Image, Table, Pagination, Space } from 'ant-design-vue';
|
||||||
|
import styles from './EventDetailModal.module.less';
|
||||||
|
|
||||||
|
interface EventDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
record: any;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Mock 数据 =====
|
||||||
|
const MOCK_DETAIL = {
|
||||||
|
name: '第二届全国地学羽毛球邀请赛',
|
||||||
|
eventTime: '2026.05.26 08:00 ~ 2026.05.31 22:00',
|
||||||
|
descr:
|
||||||
|
'赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍赛事介绍。',
|
||||||
|
registerTime: '2026.05.24 08:00 ~ 2026.05.26 22:00',
|
||||||
|
cancelDeadline: '2026.05.25 22:00',
|
||||||
|
region: '北京市 北京市 东城区',
|
||||||
|
address: '李宁体育馆',
|
||||||
|
venueNo: '李宁体育馆',
|
||||||
|
publicEvent: '在首页展示赛事',
|
||||||
|
needIdCard: '无需提供',
|
||||||
|
needIdCardImage: '无需提供',
|
||||||
|
createTime: '2026.05.26 08:00',
|
||||||
|
creator: '张三',
|
||||||
|
status: '报名中',
|
||||||
|
enrolledCount: 100,
|
||||||
|
cancelledCount: 1,
|
||||||
|
maxCount: 1000,
|
||||||
|
crossedCount: 50,
|
||||||
|
completedCount: 50,
|
||||||
|
cover: 'https://picsum.photos/seed/eventcover/600/300',
|
||||||
|
announcement:
|
||||||
|
'赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告赛事公告。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 假组别数据
|
||||||
|
const MOCK_GROUPS = [
|
||||||
|
{
|
||||||
|
key: 'group1',
|
||||||
|
title: '组别1:男子单打(单打)',
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
name: '张三',
|
||||||
|
gender: '男',
|
||||||
|
phone: '12345678995',
|
||||||
|
idNo: 'xxxxxxxxxxxxxxxx',
|
||||||
|
idImg: '',
|
||||||
|
},
|
||||||
|
{ key: '2', name: '李四', gender: '男', phone: '', idNo: '', idImg: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'group2',
|
||||||
|
title: '组别2:女子双打(双打)',
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
name: '张三',
|
||||||
|
gender: '女',
|
||||||
|
phone: '12345678995',
|
||||||
|
idNo: 'xxxxxxxxxxxxxxxx',
|
||||||
|
idImg: '',
|
||||||
|
},
|
||||||
|
{ key: '2', name: '李四', gender: '女', phone: '', idNo: '', idImg: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 假分组数据
|
||||||
|
const MOCK_DIVISIONS = ['分组1:男子小组1(循环赛)', '分组2:女子小组1(淘汰赛)'];
|
||||||
|
|
||||||
|
const PLAYER_COLUMNS = [
|
||||||
|
{ title: '选手', dataIndex: 'name', key: 'name', width: 100 },
|
||||||
|
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 80 },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 150 },
|
||||||
|
{ title: '证件号', dataIndex: 'idNo', key: 'idNo', width: 180 },
|
||||||
|
{
|
||||||
|
title: '证件图片',
|
||||||
|
dataIndex: 'idImg',
|
||||||
|
key: 'idImg',
|
||||||
|
width: 180,
|
||||||
|
customRender: () => (
|
||||||
|
<Space>
|
||||||
|
<Image width={48} height={32} src="https://picsum.photos/seed/idimg1/96/64" />
|
||||||
|
<Image width={48} height={32} src="https://picsum.photos/seed/idimg2/96/64" />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventDetailModal',
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
record: { type: Object, default: () => ({}) },
|
||||||
|
onClose: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
setup(props: EventDetailModalProps) {
|
||||||
|
const detail = MOCK_DETAIL;
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<Modal
|
||||||
|
visible={props.visible}
|
||||||
|
title="查看详情"
|
||||||
|
width={900}
|
||||||
|
onCancel={props.onClose}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{/* ===== 基础信息 ===== */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>基础信息</div>
|
||||||
|
<Descriptions size="small" bordered column={3} class={styles.desc}>
|
||||||
|
{/* 第一行:赛事名称(1 列) + 赛事时间(占 2 列) */}
|
||||||
|
<Descriptions.Item label="赛事名称">{detail.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="赛事时间" span={2}>
|
||||||
|
{detail.eventTime}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
{/* 第二行:赛事说明(占 3 列,独占整行) */}
|
||||||
|
<Descriptions.Item label="赛事说明" span={3}>
|
||||||
|
{detail.descr}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="报名起止时间">{detail.registerTime}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="取消报名截止时间" span={2}>
|
||||||
|
{detail.cancelDeadline}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="省市区">{detail.region}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="地点">{detail.address}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="场地编号">{detail.venueNo}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="公开活动">{detail.publicEvent}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="证件号">{detail.needIdCard}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="证件图片">{detail.needIdCardImage}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="创建时间">{detail.createTime}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建人">{detail.creator}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{detail.status}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="报名人数">{detail.enrolledCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="取消报名人数" span={1}>
|
||||||
|
{detail.cancelledCount}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="最大人数">{detail.maxCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="对岸数量">{detail.crossedCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已完成对岸数量">{detail.completedCount}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 封面 ===== */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>封面</div>
|
||||||
|
<Image src={detail.cover} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 赛事公告 ===== */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>赛事公告</div>
|
||||||
|
<div class={styles.textBlock}>{detail.announcement}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 组别信息 ===== */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>组别信息</div>
|
||||||
|
{MOCK_GROUPS.map((group) => (
|
||||||
|
<div key={group.key} class={styles.group}>
|
||||||
|
<div class={styles.groupTitle}>{group.title}</div>
|
||||||
|
<Table
|
||||||
|
columns={PLAYER_COLUMNS}
|
||||||
|
dataSource={group.players}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
<div class={styles.paginationRow}>
|
||||||
|
<Pagination
|
||||||
|
current={1}
|
||||||
|
pageSize={10}
|
||||||
|
total={5}
|
||||||
|
showSizeChanger={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 分组信息 ===== */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>分组信息</div>
|
||||||
|
<div class={styles.divisionList}>
|
||||||
|
{MOCK_DIVISIONS.map((d) => (
|
||||||
|
<div key={d} class={styles.divisionItem}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 3px solid #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textBlock {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageCell {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageCell :global(.ant-image) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 3px solid #1677ff;
|
||||||
|
}
|
||||||
|
.textBlock {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.imageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.imageCell {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.imageCell :global(.ant-image) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Modal, Image } from 'ant-design-vue';
|
||||||
|
import styles from './EventRegulationModal.module.less';
|
||||||
|
|
||||||
|
interface EventRegulationModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规程文字 */
|
||||||
|
const REGULATION_TEXT = [
|
||||||
|
'一、赛事目的:推动羽毛球运动发展,提高运动员技术水平,增进体育文化交流。',
|
||||||
|
'二、参赛资格:凡身体健康、年龄符合要求的羽毛球爱好者均可报名参加。',
|
||||||
|
'三、比赛规则:采用中国羽毛球协会审定的最新《羽毛球竞赛规则》。',
|
||||||
|
'四、赛制安排:比赛分小组赛和淘汰赛两个阶段,小组赛采用单循环,淘汰赛采用单败淘汰。',
|
||||||
|
'五、器材要求:参赛运动员自备球拍,比赛用球由组委会统一提供。',
|
||||||
|
'六、裁判设置:每场比赛设主裁判1名、副裁判2名,确保比赛公平公正。',
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
/** 规程图片(mock:8 张示例图,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 },
|
||||||
|
onClose: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
setup(props: EventRegulationModalProps) {
|
||||||
|
return () => (
|
||||||
|
<Modal
|
||||||
|
visible={props.visible}
|
||||||
|
title="赛事规程"
|
||||||
|
width={760}
|
||||||
|
onCancel={props.onClose}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{/* 规程文字 */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<div class={styles.sectionTitle}>规程文字</div>
|
||||||
|
<div class={styles.textBlock}>{REGULATION_TEXT}</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} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 表格 body 容器:占满 flex 剩余空间,并确保 antd 嵌套 div 逐层传递 height: 100%,
|
||||||
|
* 使得 Table scroll.y = 'calc(100% - 55px)' 中的 100% 能正确解析。
|
||||||
|
*/
|
||||||
|
.tableBody {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.ant-spin-nested-loading,
|
||||||
|
.ant-spin-container,
|
||||||
|
.ant-table,
|
||||||
|
.ant-table-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Table,
|
||||||
|
Form,
|
||||||
|
Space,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
Cascader,
|
||||||
|
Tooltip,
|
||||||
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
useEventListModel,
|
||||||
|
EVENT_STATUS_OPTIONS,
|
||||||
|
PUBLIC_EVENT_OPTIONS,
|
||||||
|
ID_CARD_OPTIONS,
|
||||||
|
ID_CARD_IMAGE_OPTIONS,
|
||||||
|
} from './model/useEventListModel';
|
||||||
|
import { regionOptions } from '@/utils/areaData';
|
||||||
|
import { useContainerSize, useState } from '@/hooks';
|
||||||
|
import EventRegulationModal from './components/EventRegulationModal';
|
||||||
|
import EventDetailModal from './components/EventDetailModal';
|
||||||
|
import styles from './index.module.less';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bodyCell 渲染函数
|
||||||
|
*/
|
||||||
|
function renderBodyCell({
|
||||||
|
column,
|
||||||
|
text,
|
||||||
|
record,
|
||||||
|
onView,
|
||||||
|
onRemove,
|
||||||
|
onViewRegulation,
|
||||||
|
}: {
|
||||||
|
column: any;
|
||||||
|
text: any;
|
||||||
|
record: any;
|
||||||
|
onView: (record: any) => void;
|
||||||
|
onRemove: (record: any) => void;
|
||||||
|
onViewRegulation: (record: any) => void;
|
||||||
|
}) {
|
||||||
|
if (column.dataIndex === 'eventName') {
|
||||||
|
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 isRemoved = record.status === 'removed';
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => onView(record)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger={!isRemoved}
|
||||||
|
style={isRemoved ? { color: '#52c41a' } : {}}
|
||||||
|
onClick={() => onRemove(record)}
|
||||||
|
>
|
||||||
|
{isRemoved ? '上架' : '下架'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 赛事列表
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventList',
|
||||||
|
setup() {
|
||||||
|
const {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
} = useEventListModel();
|
||||||
|
|
||||||
|
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [regulationModalVisible, setRegulationModalVisible] = useState<boolean>(false);
|
||||||
|
const [detailModalVisible, setDetailModalVisible] = useState<boolean>(false);
|
||||||
|
const [currentRecord, setCurrentRecord] = useState<any>({});
|
||||||
|
|
||||||
|
const handleView = (record: any) => {
|
||||||
|
console.log('查看赛事:', record.eventId);
|
||||||
|
setCurrentRecord(record);
|
||||||
|
setDetailModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (record: any) => {
|
||||||
|
const isRemoved = record.status === 'removed';
|
||||||
|
const actionText = isRemoved ? '上架' : '下架';
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${actionText}确认`,
|
||||||
|
content: `确认${actionText}"${record.eventName}"该赛事吗,下架后用户将无法搜索和报名该赛事,已有报名和订单不受影响。`,
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => {
|
||||||
|
console.log(`${actionText}赛事:`, record.eventId);
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRegulation = (record: any) => {
|
||||||
|
console.log('查看规程:', record.eventId);
|
||||||
|
setCurrentRecord(record);
|
||||||
|
setRegulationModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 最终表格列:模型列 + 赛事规程 + 操作 */
|
||||||
|
const tableColumns = [
|
||||||
|
...columns,
|
||||||
|
{ title: '赛事规程', key: 'regulation', width: 100, align: 'center' as const },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="page-container">
|
||||||
|
{/* ===== 筛选区 ===== */}
|
||||||
|
<div class="page-filter">
|
||||||
|
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||||
|
<Form.Item label="创建时间" name="createTimeRange">
|
||||||
|
<RangePicker
|
||||||
|
value={filterForm.createTimeRange as any}
|
||||||
|
style={{ width: '260px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.createTimeRange = val)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="赛事时间" name="eventTimeRange">
|
||||||
|
<RangePicker
|
||||||
|
value={filterForm.eventTimeRange as any}
|
||||||
|
style={{ width: '260px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.eventTimeRange = val)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="赛事名称" name="searchName">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入赛事名称"
|
||||||
|
style={{ width: '180px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="创建人" name="searchCreator">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入创建人"
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" name="status">
|
||||||
|
<Select
|
||||||
|
value={filterForm.status}
|
||||||
|
options={EVENT_STATUS_OPTIONS as any}
|
||||||
|
style={{ width: '110px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="公开活动" name="publicEvent">
|
||||||
|
<Select
|
||||||
|
value={filterForm.publicEvent}
|
||||||
|
options={PUBLIC_EVENT_OPTIONS as any}
|
||||||
|
style={{ width: '170px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.publicEvent = val || '')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="证件号" name="needIdCard">
|
||||||
|
<Select
|
||||||
|
value={filterForm.needIdCard}
|
||||||
|
options={ID_CARD_OPTIONS as any}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.needIdCard = val || '')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="证件图片" name="needIdCardImage">
|
||||||
|
<Select
|
||||||
|
value={filterForm.needIdCardImage}
|
||||||
|
options={ID_CARD_IMAGE_OPTIONS as any}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
allowClear
|
||||||
|
onUpdate:value={(val: any) => (filterForm.needIdCardImage = val || '')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="省市区" name="region">
|
||||||
|
<Cascader
|
||||||
|
value={filterForm.region as any}
|
||||||
|
options={regionOptions}
|
||||||
|
style={{ width: '220px' }}
|
||||||
|
placeholder="请选择省市区"
|
||||||
|
allowClear
|
||||||
|
changeOnSelect
|
||||||
|
onUpdate:value={(val: any) => (filterForm.region = val || [])}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 表格区 ===== */}
|
||||||
|
<div class="page-table">
|
||||||
|
<div ref={containerRef} class={styles.tableBody}>
|
||||||
|
<Table
|
||||||
|
columns={tableColumns}
|
||||||
|
dataSource={dataSource.value}
|
||||||
|
loading={loading.value}
|
||||||
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
|
pagination={false}
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
bodyCell: (args: any) =>
|
||||||
|
renderBodyCell({
|
||||||
|
...args,
|
||||||
|
onView: handleView,
|
||||||
|
onRemove: handleRemove,
|
||||||
|
onViewRegulation: handleViewRegulation,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 独立分页,右下方 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
padding: '10px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pagination
|
||||||
|
current={pagination.value.current}
|
||||||
|
pageSize={pagination.value.pageSize}
|
||||||
|
total={pagination.value.total}
|
||||||
|
showSizeChanger
|
||||||
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
onShowSizeChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 弹窗区 ===== */}
|
||||||
|
<EventRegulationModal
|
||||||
|
visible={regulationModalVisible.value}
|
||||||
|
onClose={() => setRegulationModalVisible(false)}
|
||||||
|
/>
|
||||||
|
<EventDetailModal
|
||||||
|
visible={detailModalVisible.value}
|
||||||
|
record={currentRecord.value}
|
||||||
|
onClose={() => setDetailModalVisible(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
|
import { message, Tag } from 'ant-design-vue';
|
||||||
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 常量
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 赛事状态选项 */
|
||||||
|
export const EVENT_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'enrolling', label: '报名中' },
|
||||||
|
{ value: 'ongoing', label: '进行中' },
|
||||||
|
{ value: 'finished', label: '已结束' },
|
||||||
|
{ value: 'deleted', label: '已删除' },
|
||||||
|
{ value: 'removed', label: '已下架' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 公开活动选项 */
|
||||||
|
export const PUBLIC_EVENT_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'show', label: '在首页展示赛事' },
|
||||||
|
{ value: 'hide', label: '不在首页展示赛事' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 证件号选项 */
|
||||||
|
export const ID_CARD_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'no', label: '无需提供' },
|
||||||
|
{ value: 'yes', label: '需提供' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 证件图片选项 */
|
||||||
|
export const ID_CARD_IMAGE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'no', label: '无需提供' },
|
||||||
|
{ value: 'yes', label: '需提供' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 假数据
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const MOCK_DATA = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
key: `${i + 1}`,
|
||||||
|
eventId: `EV2026070100${String(i + 1).padStart(2, '0')}`,
|
||||||
|
eventName:
|
||||||
|
i % 3 === 0
|
||||||
|
? '2026全国青少年羽毛球锦标赛暨体育文化交流大会'
|
||||||
|
: i % 3 === 1
|
||||||
|
? '国际马拉松城市联赛'
|
||||||
|
: '全民健身运动会',
|
||||||
|
eventTimeStart: '2026-08-12 09:00',
|
||||||
|
eventTimeEnd: '2026-08-15 18:00',
|
||||||
|
province: '广东省',
|
||||||
|
city: '深圳市',
|
||||||
|
district: '南山区',
|
||||||
|
address: '深圳湾体育中心',
|
||||||
|
needIdCard: i % 2 === 0 ? '是' : '否',
|
||||||
|
needIdCardImage: i % 2 === 0 ? '是' : '否',
|
||||||
|
creatorNickname: ['张运营', '李管理', '王策划', '赵赛事'][i % 4],
|
||||||
|
creatorPhone: ['13800138001', '13900139002', '13700137003', '13600136004'][i % 4],
|
||||||
|
createTime: '2026-07-01 14:30:00',
|
||||||
|
status: (['enrolling', 'ongoing', 'finished', 'deleted', 'removed'] as const)[i % 5],
|
||||||
|
enrolledCount: [128, 256, 512, 64, 0][i % 5],
|
||||||
|
cancelledCount: [5, 12, 8, 3, 0][i % 5],
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 状态标签映射 */
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
enrolling: { label: '报名中', color: 'blue' },
|
||||||
|
ongoing: { label: '进行中', color: 'green' },
|
||||||
|
finished: { label: '已结束', color: 'default' },
|
||||||
|
deleted: { label: '已删除', color: 'red' },
|
||||||
|
removed: { label: '已下架', color: 'orange' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Model
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 赛事列表页数据模型
|
||||||
|
*/
|
||||||
|
export function useEventListModel() {
|
||||||
|
// ===== 筛选条件 =====
|
||||||
|
const filterForm = reactive({
|
||||||
|
createTimeRange: null as [string, string] | null,
|
||||||
|
eventTimeRange: null as [string, string] | null,
|
||||||
|
searchName: '',
|
||||||
|
searchCreator: '',
|
||||||
|
status: '',
|
||||||
|
region: [] as string[],
|
||||||
|
publicEvent: '',
|
||||||
|
needIdCard: '',
|
||||||
|
needIdCardImage: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 可搜索字段防抖
|
||||||
|
const { debouncedValue: debouncedName } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchName') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
const { debouncedValue: debouncedCreator } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchCreator') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 表格状态 =====
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [dataSource, setDataSource] = useState<any[]>(MOCK_DATA);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: MOCK_DATA.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 表格列配置 =====
|
||||||
|
const columns = [
|
||||||
|
{ title: '赛事ID', dataIndex: 'eventId', key: 'eventId', width: 180 },
|
||||||
|
{
|
||||||
|
title: '赛事名称',
|
||||||
|
dataIndex: 'eventName',
|
||||||
|
key: 'eventName',
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '赛事时间',
|
||||||
|
key: 'eventTime',
|
||||||
|
width: 260,
|
||||||
|
customRender: ({ record }: { record: any }) =>
|
||||||
|
`${record.eventTimeStart} 至 ${record.eventTimeEnd}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '省市区',
|
||||||
|
key: 'region',
|
||||||
|
width: 200,
|
||||||
|
customRender: ({ record }: { record: any }) =>
|
||||||
|
`${record.province} ${record.city} ${record.district}`,
|
||||||
|
},
|
||||||
|
{ title: '地点', dataIndex: 'address', key: 'address', width: 180 },
|
||||||
|
{
|
||||||
|
title: '是否提供证件号',
|
||||||
|
dataIndex: 'needIdCard',
|
||||||
|
key: 'needIdCard',
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '是否提供证件图片',
|
||||||
|
dataIndex: 'needIdCardImage',
|
||||||
|
key: 'needIdCardImage',
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建人昵称',
|
||||||
|
dataIndex: 'creatorNickname',
|
||||||
|
key: 'creatorNickname',
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建人手机号',
|
||||||
|
dataIndex: 'creatorPhone',
|
||||||
|
key: 'creatorPhone',
|
||||||
|
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, color: 'default' };
|
||||||
|
return h(Tag, { color: info.color }, () => info.label);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '报名人数', dataIndex: 'enrolledCount', key: 'enrolledCount', width: 90 },
|
||||||
|
{ title: '取消报名人数', dataIndex: 'cancelledCount', key: 'cancelledCount', width: 140 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 计算属性 =====
|
||||||
|
const hasFilter = computed(() => {
|
||||||
|
return (
|
||||||
|
filterForm.createTimeRange !== null ||
|
||||||
|
filterForm.eventTimeRange !== null ||
|
||||||
|
debouncedName.value.trim() !== '' ||
|
||||||
|
debouncedCreator.value.trim() !== '' ||
|
||||||
|
filterForm.status !== '' ||
|
||||||
|
filterForm.region.length > 0 ||
|
||||||
|
filterForm.publicEvent !== '' ||
|
||||||
|
filterForm.needIdCard !== '' ||
|
||||||
|
filterForm.needIdCardImage !== ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 方法 =====
|
||||||
|
|
||||||
|
/** 查询 */
|
||||||
|
const handleSearch = useThrottleFn(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
console.log('搜索条件:', {
|
||||||
|
createTimeRange: filterForm.createTimeRange,
|
||||||
|
eventTimeRange: filterForm.eventTimeRange,
|
||||||
|
name: debouncedName.value,
|
||||||
|
creator: debouncedCreator.value,
|
||||||
|
status: filterForm.status,
|
||||||
|
region: filterForm.region,
|
||||||
|
publicEvent: filterForm.publicEvent,
|
||||||
|
needIdCard: filterForm.needIdCard,
|
||||||
|
needIdCardImage: filterForm.needIdCardImage,
|
||||||
|
});
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
setDataSource(MOCK_DATA);
|
||||||
|
setPagination({ ...pagination.value, total: MOCK_DATA.length });
|
||||||
|
message.success('查询成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.msg || '查询失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
/** 重置 */
|
||||||
|
const handleReset = useThrottleFn(() => {
|
||||||
|
filterForm.createTimeRange = null;
|
||||||
|
filterForm.eventTimeRange = null;
|
||||||
|
filterForm.searchName = '';
|
||||||
|
filterForm.searchCreator = '';
|
||||||
|
filterForm.status = '';
|
||||||
|
filterForm.region = [];
|
||||||
|
filterForm.publicEvent = '';
|
||||||
|
filterForm.needIdCard = '';
|
||||||
|
filterForm.needIdCardImage = '';
|
||||||
|
setPagination({ current: 1, pageSize: 10, total: MOCK_DATA.length });
|
||||||
|
setDataSource(MOCK_DATA);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setPagination({ ...pagination.value, current: page, pageSize });
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
hasFilter,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
.container {
|
||||||
|
// 容器
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Button, Input, Table, Form, Space } from 'ant-design-vue';
|
||||||
|
import { useOrderModel } from './model/useOrderModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单管理
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventOrders',
|
||||||
|
setup() {
|
||||||
|
const {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
} = useOrderModel();
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-filter">
|
||||||
|
<Form layout="inline" model={filterForm}>
|
||||||
|
<Form.Item label="订单编号" name="searchOrderId">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入订单编号"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="赛事名称" name="searchEventName">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入赛事名称"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="用户名" name="searchUserName">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-table">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource.value}
|
||||||
|
loading={loading.value}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
total: pagination.value.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`,
|
||||||
|
onChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { computed, reactive, toRef, Ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单管理页数据模型
|
||||||
|
*/
|
||||||
|
export function useOrderModel() {
|
||||||
|
// ===== 筛选条件(合并在一个 reactive 对象中)=====
|
||||||
|
const filterForm = reactive({
|
||||||
|
searchOrderId: '',
|
||||||
|
searchEventName: '',
|
||||||
|
searchUserName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 各字段防抖 300ms
|
||||||
|
const { debouncedValue: debouncedOrderId } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchOrderId') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
const { debouncedValue: debouncedEventName } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchEventName') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
const { debouncedValue: debouncedUserName } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchUserName') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 表格状态 =====
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [dataSource, setDataSource] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 表格列配置 =====
|
||||||
|
const columns = [
|
||||||
|
{ title: '订单编号', dataIndex: 'orderId', key: 'orderId' },
|
||||||
|
{ title: '赛事名称', dataIndex: 'eventName', key: 'eventName' },
|
||||||
|
{ title: '用户名', dataIndex: 'userName', key: 'userName' },
|
||||||
|
{ title: '订单金额', dataIndex: 'amount', key: 'amount' },
|
||||||
|
{ title: '支付状态', dataIndex: 'payStatus', key: 'payStatus' },
|
||||||
|
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 计算属性 =====
|
||||||
|
const hasFilter = computed(() => {
|
||||||
|
return (
|
||||||
|
debouncedOrderId.value.trim() !== '' ||
|
||||||
|
debouncedEventName.value.trim() !== '' ||
|
||||||
|
debouncedUserName.value.trim() !== ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 方法 =====
|
||||||
|
|
||||||
|
/** 查询(节流 500ms) */
|
||||||
|
const handleSearch = useThrottleFn(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
console.log('搜索条件:', {
|
||||||
|
orderId: debouncedOrderId.value,
|
||||||
|
eventName: debouncedEventName.value,
|
||||||
|
userName: debouncedUserName.value,
|
||||||
|
});
|
||||||
|
message.success('查询成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.msg || '查询失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
/** 重置(节流 500ms) */
|
||||||
|
const handleReset = useThrottleFn(() => {
|
||||||
|
filterForm.searchOrderId = '';
|
||||||
|
filterForm.searchEventName = '';
|
||||||
|
filterForm.searchUserName = '';
|
||||||
|
setPagination({ current: 1, pageSize: 10, total: 0 });
|
||||||
|
setDataSource([]);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setPagination({ ...pagination.value, current: page, pageSize });
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
hasFilter,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
.container {
|
||||||
|
// 容器
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Button, Input, Table, Form, Space } from 'ant-design-vue';
|
||||||
|
import { useUserModel } from './model/useUserModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventUsers',
|
||||||
|
setup() {
|
||||||
|
const {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
} = useUserModel();
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-filter">
|
||||||
|
<Form layout="inline" model={filterForm}>
|
||||||
|
<Form.Item label="用户昵称" name="searchUserName">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入用户昵称"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="手机号" name="searchPhone">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-table">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource.value}
|
||||||
|
loading={loading.value}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
total: pagination.value.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`,
|
||||||
|
onChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { computed, reactive, toRef, Ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表页数据模型
|
||||||
|
*/
|
||||||
|
export function useUserModel() {
|
||||||
|
// ===== 筛选条件(合并在一个 reactive 对象中)=====
|
||||||
|
const filterForm = reactive({
|
||||||
|
searchUserName: '',
|
||||||
|
searchPhone: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 各字段防抖 300ms
|
||||||
|
const { debouncedValue: debouncedUserName } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchUserName') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
const { debouncedValue: debouncedPhone } = useDebounce(
|
||||||
|
toRef(filterForm, 'searchPhone') as Ref<string>,
|
||||||
|
{ delay: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 表格状态 =====
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [dataSource, setDataSource] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 表格列配置 =====
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户昵称', dataIndex: 'nickName', key: 'nickName' },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
|
||||||
|
{ title: '报名赛事', dataIndex: 'eventName', key: 'eventName' },
|
||||||
|
{ title: '报名时间', dataIndex: 'registerTime', key: 'registerTime' },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 计算属性 =====
|
||||||
|
const hasFilter = computed(() => {
|
||||||
|
return debouncedUserName.value.trim() !== '' || debouncedPhone.value.trim() !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 方法 =====
|
||||||
|
|
||||||
|
/** 查询(节流 500ms) */
|
||||||
|
const handleSearch = useThrottleFn(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
console.log('搜索条件:', {
|
||||||
|
userName: debouncedUserName.value,
|
||||||
|
phone: debouncedPhone.value,
|
||||||
|
});
|
||||||
|
message.success('查询成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.msg || '查询失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
/** 重置(节流 500ms) */
|
||||||
|
const handleReset = useThrottleFn(() => {
|
||||||
|
filterForm.searchUserName = '';
|
||||||
|
filterForm.searchPhone = '';
|
||||||
|
setPagination({ current: 1, pageSize: 10, total: 0 });
|
||||||
|
setDataSource([]);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setPagination({ ...pagination.value, current: page, pageSize });
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterForm,
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
hasFilter,
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handlePageChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type { MenuNode, MenuItemRaw } from '@/types';
|
||||||
import router, { resetRouter } from '@/router';
|
import router, { resetRouter } from '@/router';
|
||||||
import { fetchMenuTree } from '@/api/menu';
|
import { fetchMenuTree } from '@/api/menu';
|
||||||
import { pageModules } from '@/router/glob';
|
import { pageModules } from '@/router/glob';
|
||||||
|
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 import.meta.glob 动态收集 src/pages/ 下所有页面组件
|
* 使用 import.meta.glob 动态收集 src/pages/ 下所有页面组件
|
||||||
|
|
@ -24,15 +25,11 @@ import { pageModules } from '@/router/glob';
|
||||||
function resolveComponent(component?: string): RouteRecordRaw['component'] | undefined {
|
function resolveComponent(component?: string): RouteRecordRaw['component'] | undefined {
|
||||||
if (!component) return undefined;
|
if (!component) return undefined;
|
||||||
|
|
||||||
// 构建 glob key
|
|
||||||
const globKey = `/src/pages/${component}/index.tsx`;
|
const globKey = `/src/pages/${component}/index.tsx`;
|
||||||
|
|
||||||
// 直接匹配
|
|
||||||
if (pageModules[globKey]) {
|
if (pageModules[globKey]) {
|
||||||
return pageModules[globKey];
|
return pageModules[globKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:尝试带 @ 前缀匹配(Vite alias)
|
|
||||||
const aliasKey = `@/pages/${component}/index.tsx`;
|
const aliasKey = `@/pages/${component}/index.tsx`;
|
||||||
if (pageModules[aliasKey]) {
|
if (pageModules[aliasKey]) {
|
||||||
return pageModules[aliasKey];
|
return pageModules[aliasKey];
|
||||||
|
|
@ -60,43 +57,94 @@ const state = reactive<MenuState>({
|
||||||
homePath: '/dashboard',
|
homePath: '/dashboard',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点的可用子节点(排除 disabled 和 externalLink)
|
||||||
|
*/
|
||||||
|
function getEnabledChildren(node: MenuNode): MenuNode[] {
|
||||||
|
if (!node.children?.length) return [];
|
||||||
|
return node.children.filter((child) => !child.disabled && !child.externalLink);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
|
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 过滤 disabled / hideInMenu / externalLink 节点
|
||||||
|
* - 父节点有 enabledChildren 时递归转换;若所有子节点都被过滤且父节点无 component,则父节点也不显示
|
||||||
|
* - key 统一加 '/' 前缀,与 route.path 匹配(如 '/dashboard'、'/events/list')
|
||||||
*/
|
*/
|
||||||
function transformMenuNode(nodes: MenuNode[]): MenuItemRaw[] {
|
function transformMenuNode(nodes: MenuNode[]): MenuItemRaw[] {
|
||||||
return nodes
|
const result: MenuItemRaw[] = [];
|
||||||
.filter((node) => !node.hideInMenu && !node.externalLink)
|
|
||||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
for (const node of nodes) {
|
||||||
.map((node) => {
|
if (node.disabled || node.hideInMenu || node.externalLink) continue;
|
||||||
|
|
||||||
|
const enabledChildren = getEnabledChildren(node);
|
||||||
const item: MenuItemRaw = {
|
const item: MenuItemRaw = {
|
||||||
key: node.path,
|
key: '/' + node.path,
|
||||||
label: node.name,
|
label: node.name,
|
||||||
iconName: node.icon,
|
iconName: node.icon,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.children?.length) {
|
if (enabledChildren.length > 0) {
|
||||||
item.children = transformMenuNode(node.children);
|
const childItems = transformMenuNode(enabledChildren);
|
||||||
|
if (childItems.length > 0) {
|
||||||
|
item.children = childItems;
|
||||||
|
}
|
||||||
|
// 子节点全部被过滤 → 父节点也不显示
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
result.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aNode = nodes.find((n) => '/' + n.path === a.key);
|
||||||
|
const bNode = nodes.find((n) => '/' + n.path === b.key);
|
||||||
|
return (aNode?.sort ?? 0) - (bNode?.sort ?? 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将后端 MenuNode 递归转为 vue-router RouteRecordRaw
|
* 将后端 MenuNode 递归转为 vue-router RouteRecordRaw
|
||||||
* component 字段通过 resolveComponent() 动态解析
|
*
|
||||||
|
* node.path 存储完整路径(如 'events/list'),
|
||||||
|
* 但 Vue Router 嵌套路由下子节点的 path 必须相对于父节点(如 'list')。
|
||||||
|
* 通过 parentPath 参数实现自动剥离前缀。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 跳过 disabled / externalLink 节点
|
||||||
|
* - 父节点无 component 且所有子节点被过滤 → 跳过
|
||||||
|
* - 父节点无 component 时,redirect 自动指向第一个可用子路由(相对路径)
|
||||||
*/
|
*/
|
||||||
function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
|
function transformMenuToRoutes(nodes: MenuNode[], parentPath = ''): RouteRecordRaw[] {
|
||||||
const routes: RouteRecordRaw[] = [];
|
const routes: RouteRecordRaw[] = [];
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
// 外链节点不注册路由
|
if (node.disabled || node.externalLink) continue;
|
||||||
if (node.externalLink) continue;
|
|
||||||
|
|
||||||
|
const enabledChildren = getEnabledChildren(node);
|
||||||
const componentLoader = resolveComponent(node.component);
|
const componentLoader = resolveComponent(node.component);
|
||||||
|
|
||||||
|
// 无组件且无可用子节点 → 跳过
|
||||||
|
if (!componentLoader && enabledChildren.length === 0) continue;
|
||||||
|
|
||||||
|
// 计算 Vue Router 路由 path: 根节点用完整路径,子节点剥离父级前缀
|
||||||
|
const routePath = parentPath
|
||||||
|
? node.path.slice(parentPath.length + 1) // 'events/list' → 'list'
|
||||||
|
: node.path;
|
||||||
|
|
||||||
const route: RouteRecordRaw = {
|
const route: RouteRecordRaw = {
|
||||||
path: node.path,
|
path: routePath,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
meta: {
|
meta: {
|
||||||
title: node.name,
|
title: node.name,
|
||||||
|
|
@ -111,11 +159,13 @@ function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
|
||||||
route.component = componentLoader;
|
route.component = componentLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children?.length) {
|
if (enabledChildren.length > 0) {
|
||||||
route.children = transformMenuToRoutes(node.children);
|
// 递归时传入当前节点的完整路径作为父级前缀
|
||||||
// 没有组件的父节点自动重定向到第一个子节点
|
route.children = transformMenuToRoutes(enabledChildren, node.path);
|
||||||
if (!componentLoader && node.children[0]) {
|
// 无组件的父节点 → redirect 指向第一个可用子路由(相对路径)
|
||||||
route.redirect = node.children[0].path;
|
if (!componentLoader) {
|
||||||
|
const firstChildRelativePath = enabledChildren[0].path.slice(node.path.length + 1);
|
||||||
|
route.redirect = firstChildRelativePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,14 +177,19 @@ function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取第一个可见菜单的路径(用作默认首页)
|
* 获取第一个可见菜单的路径(用作默认首页)
|
||||||
|
* 跳过 disabled / hideInMenu / externalLink 节点
|
||||||
|
* 返回带 '/' 前缀的完整路径
|
||||||
*/
|
*/
|
||||||
function getFirstMenuPath(nodes: MenuNode[]): string {
|
function getFirstMenuPath(nodes: MenuNode[]): string {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!node.hideInMenu && !node.externalLink && !node.children?.length) {
|
if (node.disabled || node.externalLink) continue;
|
||||||
return node.path;
|
|
||||||
|
const enabledChildren = getEnabledChildren(node);
|
||||||
|
if (!node.hideInMenu && enabledChildren.length === 0) {
|
||||||
|
return '/' + node.path;
|
||||||
}
|
}
|
||||||
if (node.children?.length) {
|
if (enabledChildren.length > 0) {
|
||||||
const childPath = getFirstMenuPath(node.children);
|
const childPath = getFirstMenuPath(enabledChildren);
|
||||||
if (childPath) return childPath;
|
if (childPath) return childPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,8 +201,7 @@ function getFirstMenuPath(nodes: MenuNode[]): string {
|
||||||
* 动态路由作为其子路由挂载
|
* 动态路由作为其子路由挂载
|
||||||
*/
|
*/
|
||||||
function ensureLayoutRoute() {
|
function ensureLayoutRoute() {
|
||||||
const existing = router.hasRoute('BasicLayout');
|
if (!router.hasRoute('BasicLayout')) {
|
||||||
if (!existing) {
|
|
||||||
router.addRoute({
|
router.addRoute({
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'BasicLayout',
|
name: 'BasicLayout',
|
||||||
|
|
@ -159,61 +213,41 @@ function ensureLayoutRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册兜底菜单和路由(后端接口不可用时使用)
|
* 通用"注入路由 + 菜单"流程
|
||||||
|
* 无论是后端数据还是兜底数据,都走同一套 logic
|
||||||
*/
|
*/
|
||||||
function registerFallbackRoutes() {
|
function applyMenuTree(menuTree: MenuNode[]) {
|
||||||
|
state.menuTree = menuTree;
|
||||||
|
state.menuItems = transformMenuNode(menuTree);
|
||||||
|
state.homePath = getFirstMenuPath(menuTree);
|
||||||
|
|
||||||
ensureLayoutRoute();
|
ensureLayoutRoute();
|
||||||
|
|
||||||
router.addRoute('BasicLayout', {
|
const dynamicRoutes = transformMenuToRoutes(menuTree);
|
||||||
path: 'dashboard',
|
dynamicRoutes.forEach((route) => {
|
||||||
name: 'Dashboard',
|
router.addRoute('BasicLayout', route);
|
||||||
component: () => import('@/pages/dashboard'),
|
|
||||||
meta: { title: '工作台', icon: 'DashboardOutlined' },
|
|
||||||
});
|
});
|
||||||
router.addRoute('BasicLayout', {
|
|
||||||
path: 'about',
|
// 默认重定向到首页
|
||||||
name: 'About',
|
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
|
||||||
component: () => import('@/pages/about'),
|
|
||||||
meta: { title: '关于', icon: 'InfoCircleOutlined' },
|
|
||||||
});
|
|
||||||
router.addRoute('BasicLayout', { path: '', redirect: '/dashboard' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 暴露给组件使用的 composable
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export function useMenuStore() {
|
export function useMenuStore() {
|
||||||
const loadMenu = async () => {
|
const loadMenu = async () => {
|
||||||
if (state.loaded) return;
|
if (state.loaded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const menuTree = await fetchMenuTree();
|
const menuTree = await fetchMenuTree();
|
||||||
state.menuTree = menuTree;
|
applyMenuTree(menuTree);
|
||||||
state.menuItems = transformMenuNode(menuTree);
|
|
||||||
state.homePath = getFirstMenuPath(menuTree);
|
|
||||||
|
|
||||||
// 确保 BasicLayout 布局路由已注册
|
|
||||||
ensureLayoutRoute();
|
|
||||||
|
|
||||||
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
|
|
||||||
const dynamicRoutes = transformMenuToRoutes(menuTree);
|
|
||||||
dynamicRoutes.forEach((route) => {
|
|
||||||
router.addRoute('BasicLayout', route);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 默认重定向到首页(BasicLayout 的根路径)
|
|
||||||
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
|
|
||||||
|
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载菜单失败:', err);
|
console.error('加载菜单失败:', err);
|
||||||
// 降级:使用兜底菜单
|
// 降级:使用本地兜底菜单
|
||||||
state.menuItems = [
|
applyMenuTree(FALLBACK_MENU_NODES);
|
||||||
{ key: '/dashboard', label: '工作台', iconName: 'DashboardOutlined' },
|
|
||||||
{ key: '/about', label: '关于', iconName: 'InfoCircleOutlined' },
|
|
||||||
];
|
|
||||||
state.homePath = '/dashboard';
|
|
||||||
|
|
||||||
// 兜底路由
|
|
||||||
registerFallbackRoutes();
|
|
||||||
|
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module 'china-area-data' {
|
||||||
|
const data: Record<string, Record<string, string>>;
|
||||||
|
export default data;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,8 @@ export interface MenuNode {
|
||||||
externalLink?: string;
|
externalLink?: string;
|
||||||
/** 高亮的菜单路径(用于详情页高亮父级菜单) */
|
/** 高亮的菜单路径(用于详情页高亮父级菜单) */
|
||||||
activeMenu?: string;
|
activeMenu?: string;
|
||||||
|
/** 是否禁用(禁用后路由不注册、菜单不显示) */
|
||||||
|
disabled?: boolean;
|
||||||
/** 子菜单/子路由 */
|
/** 子菜单/子路由 */
|
||||||
children?: MenuNode[];
|
children?: MenuNode[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import areaData from 'china-area-data';
|
||||||
|
|
||||||
|
export interface CascadeOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
children?: CascadeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 china-area-data 原始数据转换为 antd Cascader 所需的 options 格式
|
||||||
|
*
|
||||||
|
* 原始数据格式: { '86': { '110000': '北京' }, '110000': { '110100': '市辖区' }, ... }
|
||||||
|
* 目标格式: [{ value: '110000', label: '北京', children: [...] }]
|
||||||
|
*/
|
||||||
|
function buildTree(parentCode: string): CascadeOption[] {
|
||||||
|
const nodes = areaData[parentCode];
|
||||||
|
if (!nodes) return [];
|
||||||
|
|
||||||
|
return Object.entries(nodes).map(([code, name]) => {
|
||||||
|
const node: CascadeOption = {
|
||||||
|
value: code,
|
||||||
|
label: name as string,
|
||||||
|
};
|
||||||
|
const children = buildTree(code);
|
||||||
|
if (children.length > 0) {
|
||||||
|
node.children = children;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 省市区三级联动数据 */
|
||||||
|
export const regionOptions: CascadeOption[] = buildTree('86');
|
||||||
Loading…
Reference in New Issue