Compare commits

..

2 Commits

Author SHA1 Message Date
Disciple_C 5523695518 feat: 运营平台基建 搭建 2026-07-23 15:00:19 +08:00
Disciple_C 3b5a908ee4 feat: 架构调整 2026-07-23 11:27:33 +08:00
60 changed files with 1870 additions and 5163 deletions

View File

@ -4,4 +4,3 @@ public
*.config.js
*.config.ts
start-debug.js
src/assets/iconfont

View File

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"
npx --no -- commitlint --edit $1

View File

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View File

@ -12,7 +12,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[less]": {
"editor.defaultFormatter": "vscode.css-language-features"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.associations": {
"*.vue": "vue"

View File

@ -1,17 +0,0 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
chrome: '80',
firefox: '75',
safari: '14',
edge: '80',
},
useBuiltIns: 'usage',
corejs: 3,
},
],
],
};

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CPMS 运营平台" />
<title>CPMS 运营平台</title>
<meta name="description" content="六个羽友赛事运营平台" />
<title>六个羽友赛事运营平台</title>
</head>
<body>
<div id="app"></div>

207
package-lock.json generated
View File

@ -9,7 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@yp-component/root": "^0.0.46",
"ant-design-vue": "^4.0.0",
"express": "^5.2.1",
"html2canvas": "^1.4.1",
@ -20,23 +19,24 @@
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@rollup/plugin-strip": "^3.0.4",
"@types/node": "^20.10.0",
"@types/qs": "^6.15.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"eslint": "^8.57.1",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue": "^9.0.0",
"husky": "^9.1.7",
"less": "^4.6.4",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-eslint-parser": "^10.4.1",
"vite-plugin-remove-console": "^2.2.0",
"vue-tsc": "^2.0.0"
},
"engines": {
@ -941,6 +941,65 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-strip": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@rollup/plugin-strip/-/plugin-strip-3.0.4.tgz",
"integrity": "sha512-LDRV49ZaavxUo2YoKKMQjCxzCxugu1rCPQa0lDYBOWLj6vtzBMr8DcoJjsmg+s450RbKbe3qI9ZLaSO+O1oNbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2",
"cpu": [
@ -986,13 +1045,6 @@
"url": "https://ko-fi.com/dangreen"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "http://npm.nps.yunvip123.cn/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"dev": true,
@ -1023,8 +1075,6 @@
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "http://npm.nps.yunvip123.cn/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1059,8 +1109,6 @@
},
"node_modules/@typescript-eslint/parser": {
"version": "6.21.0",
"resolved": "http://npm.nps.yunvip123.cn/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@ -1440,15 +1488,6 @@
"version": "3.5.39",
"license": "MIT"
},
"node_modules/@yp-component/root": {
"version": "0.0.46",
"resolved": "http://npm.nps.yunvip123.cn/@yp-component/root/-/root-0.0.46.tgz",
"integrity": "sha512-umgaFa2x/gEtlvXsrqbu9lgcUU/j8K8Jj4p1n45vJK6c5ysBMQMj6sYWT0Jr8YO65ofh9BSMPl17n8jlknY5xA==",
"dependencies": {
"cropperjs": "^1.6.2",
"vue": "^3.5.25"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"license": "MIT",
@ -2056,12 +2095,6 @@
"typescript": ">=5"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "http://npm.nps.yunvip123.cn/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -2727,9 +2760,6 @@
},
"node_modules/eslint": {
"version": "8.57.1",
"resolved": "http://npm.nps.yunvip123.cn/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2824,8 +2854,6 @@
},
"node_modules/eslint-plugin-vue": {
"version": "9.33.0",
"resolved": "http://npm.nps.yunvip123.cn/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz",
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2845,31 +2873,6 @@
"eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-vue/node_modules/vue-eslint-parser": {
"version": "9.4.3",
"resolved": "http://npm.nps.yunvip123.cn/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
"integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"eslint-scope": "^7.1.1",
"eslint-visitor-keys": "^3.3.0",
"espree": "^9.3.1",
"esquery": "^1.4.0",
"lodash": "^4.17.21",
"semver": "^7.3.6"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
},
"peerDependencies": {
"eslint": ">=6.0.0"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"dev": true,
@ -3030,6 +3033,8 @@
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "http://192.168.2.127:4873/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@ -5779,6 +5784,13 @@
}
}
},
"node_modules/vite-plugin-remove-console": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/vite-plugin-remove-console/-/vite-plugin-remove-console-2.2.0.tgz",
"integrity": "sha512-qgjh5pz75MdE9Kzs8J0kBwaCfifHV0ezRbB9rpGsIOxam+ilcGV7WOk91vFJXquzRmiKrFh3Hxlh0JJWAmXTbQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
@ -5806,77 +5818,26 @@
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.1",
"resolved": "http://npm.nps.yunvip123.cn/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz",
"integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==",
"version": "9.4.3",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
"eslint-visitor-keys": "^4.2.0 || ^5.0.0",
"espree": "^10.3.0 || ^11.0.0",
"esquery": "^1.6.0",
"semver": "^7.6.3"
"debug": "^4.3.4",
"eslint-scope": "^7.1.1",
"eslint-visitor-keys": "^3.3.0",
"espree": "^9.3.1",
"esquery": "^1.4.0",
"lodash": "^4.17.21",
"semver": "^7.3.6"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "http://npm.nps.yunvip123.cn/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "http://npm.nps.yunvip123.cn/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/espree": {
"version": "11.2.0",
"resolved": "http://npm.nps.yunvip123.cn/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
"eslint": ">=6.0.0"
}
},
"node_modules/vue-router": {

View File

@ -5,22 +5,19 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"serve": "vite",
"build:dev": "vue-tsc --noEmit && vite build --mode development",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"local": "node start-debug.js",
"lint": "eslint src --ext .js,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,ts,tsx,json,less,css,md}\"",
"prepare": "husky install"
"prepare": "husky"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@yp-component/root": "^0.0.46",
"ant-design-vue": "^4.0.0",
"express": "^5.2.1",
"html2canvas": "^1.4.1",
@ -31,23 +28,24 @@
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@rollup/plugin-strip": "^3.0.4",
"@types/node": "^20.10.0",
"@types/qs": "^6.15.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"eslint": "^8.57.1",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue": "^9.0.0",
"husky": "^9.1.7",
"less": "^4.6.4",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-eslint-parser": "^10.4.1",
"vite-plugin-remove-console": "^2.2.0",
"vue-tsc": "^2.0.0"
},
"lint-staged": {

View File

@ -12,17 +12,8 @@ export default defineComponent({
setup() {
const theme = {
token: {
colorPrimary: '#1890ff',
colorSuccess: '#52c41a',
colorWarning: '#faad14',
colorError: '#ff4d4f',
colorInfo: '#1890ff',
colorBgBase: '#ffffff',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#f0f2f5',
colorTextBase: '#262626',
borderRadius: 6,
colorPrimary: '#1677ff',
borderRadius: 8,
},
};

16
src/api/menu.ts Normal file
View File

@ -0,0 +1,16 @@
import { get } from '@/utils/request';
import type { MenuNode, PermissionCode } from '@/types';
/**
* ()
*/
export function fetchMenuTree(): Promise<MenuNode[]> {
return get('/menu/tree');
}
/**
*
*/
export function fetchPermissions(): Promise<PermissionCode[]> {
return get('/permission/list');
}

View File

@ -1,545 +0,0 @@
/* Logo 字体 */
@font-face {
font-family: 'iconfont logo';
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src:
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix')
format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont')
format('svg');
}
.logo {
font-family: 'iconfont logo';
font-size: 160px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* tabs */
.nav-tabs {
position: relative;
}
.nav-tabs .nav-more {
position: absolute;
right: 0;
bottom: 0;
height: 42px;
line-height: 42px;
color: #666;
}
#tabs {
border-bottom: 1px solid #eee;
}
#tabs li {
cursor: pointer;
width: 100px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
border-bottom: 2px solid transparent;
position: relative;
z-index: 1;
margin-bottom: -1px;
color: #666;
}
#tabs .active {
border-bottom-color: #f00;
color: #222;
}
.tab-container .content {
display: none;
}
/* 页面布局 */
.main {
padding: 30px 100px;
width: 960px;
margin: 0 auto;
}
.main .logo {
color: #333;
text-align: left;
margin-bottom: 30px;
line-height: 1;
height: 110px;
margin-top: -50px;
overflow: hidden;
*zoom: 1;
}
.main .logo a {
font-size: 160px;
color: #333;
}
.helps {
margin-top: 40px;
}
.helps pre {
padding: 20px;
margin: 10px 0;
border: solid 1px #e7e1cd;
background-color: #fffdef;
overflow: auto;
}
.icon_lists {
width: 100% !important;
overflow: hidden;
*zoom: 1;
}
.icon_lists li {
width: 100px;
margin-bottom: 10px;
margin-right: 20px;
text-align: center;
list-style: none !important;
cursor: default;
}
.icon_lists li .code-name {
line-height: 1.2;
}
.icon_lists .icon {
display: block;
height: 100px;
line-height: 100px;
font-size: 42px;
margin: 10px auto;
color: #333;
-webkit-transition:
font-size 0.25s linear,
width 0.25s linear;
-moz-transition:
font-size 0.25s linear,
width 0.25s linear;
transition:
font-size 0.25s linear,
width 0.25s linear;
}
.icon_lists .icon:hover {
font-size: 100px;
}
.icon_lists .svg-icon {
/* 通过设置 font-size 来改变图标大小 */
width: 1em;
/* 图标和文字相邻时,垂直对齐 */
vertical-align: -0.15em;
/* 通过设置 color 来改变 SVG 的颜色/fill */
fill: currentColor;
/* path stroke 溢出 viewBox 部分在 IE 下会显示
normalize.css 中也包含这行 */
overflow: hidden;
}
.icon_lists li .name,
.icon_lists li .code-name {
color: #666;
}
/* markdown 样式 */
.markdown {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.highlight {
line-height: 1.5;
}
.markdown img {
vertical-align: middle;
max-width: 100%;
}
.markdown h1 {
color: #404040;
font-weight: 500;
line-height: 40px;
margin-bottom: 24px;
}
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
color: #404040;
margin: 1.6em 0 0.6em 0;
font-weight: 500;
clear: both;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 22px;
}
.markdown h3 {
font-size: 16px;
}
.markdown h4 {
font-size: 14px;
}
.markdown h5 {
font-size: 12px;
}
.markdown h6 {
font-size: 12px;
}
.markdown hr {
height: 1px;
border: 0;
background: #e9e9e9;
margin: 16px 0;
clear: both;
}
.markdown p {
margin: 1em 0;
}
.markdown > p,
.markdown > blockquote,
.markdown > .highlight,
.markdown > ol,
.markdown > ul {
width: 80%;
}
.markdown ul > li {
list-style: circle;
}
.markdown > ul li,
.markdown blockquote ul > li {
margin-left: 20px;
padding-left: 4px;
}
.markdown > ul li p,
.markdown > ol li p {
margin: 0.6em 0;
}
.markdown ol > li {
list-style: decimal;
}
.markdown > ol li,
.markdown blockquote ol > li {
margin-left: 20px;
padding-left: 4px;
}
.markdown code {
margin: 0 3px;
padding: 0 5px;
background: #eee;
border-radius: 3px;
}
.markdown strong,
.markdown b {
font-weight: 600;
}
.markdown > table {
border-collapse: collapse;
border-spacing: 0px;
empty-cells: show;
border: 1px solid #e9e9e9;
width: 95%;
margin-bottom: 24px;
}
.markdown > table th {
white-space: nowrap;
color: #333;
font-weight: 600;
}
.markdown > table th,
.markdown > table td {
border: 1px solid #e9e9e9;
padding: 8px 16px;
text-align: left;
}
.markdown > table th {
background: #f7f7f7;
}
.markdown blockquote {
font-size: 90%;
color: #999;
border-left: 4px solid #e9e9e9;
padding-left: 0.8em;
margin: 1em 0;
}
.markdown blockquote p {
margin: 0;
}
.markdown .anchor {
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.markdown .waiting {
color: #ccc;
}
.markdown h1:hover .anchor,
.markdown h2:hover .anchor,
.markdown h3:hover .anchor,
.markdown h4:hover .anchor,
.markdown h5:hover .anchor,
.markdown h6:hover .anchor {
opacity: 1;
display: inline-block;
}
.markdown > br,
.markdown > p > br {
clear: both;
}
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/* 代码高亮 */
/* PrismJS 1.15.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*='language-'],
pre[class*='language-'] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*='language-']::-moz-selection,
pre[class*='language-'] ::-moz-selection,
code[class*='language-']::-moz-selection,
code[class*='language-'] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*='language-']::selection,
pre[class*='language-'] ::selection,
code[class*='language-']::selection,
code[class*='language-'] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*='language-'],
pre[class*='language-'] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*='language-'],
pre[class*='language-'] {
background: #f5f2f0;
}
/* Inline code */
:not(pre) > code[class*='language-'] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
background: hsla(0, 0%, 100%, 0.5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #dd4a68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,380 +0,0 @@
{
"id": "4533260",
"name": "改版-六个羽友",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "43518948",
"name": "场馆",
"font_class": "changguan1",
"unicode": "e87b",
"unicode_decimal": 59515
},
{
"icon_id": "31394058",
"name": "灯-点亮",
"font_class": "deng-dianliang",
"unicode": "e7b9",
"unicode_decimal": 59321
},
{
"icon_id": "31394059",
"name": "灯-关闭",
"font_class": "deng-guanbi",
"unicode": "e7ba",
"unicode_decimal": 59322
},
{
"icon_id": "43469992",
"name": "刷新",
"font_class": "shuaxin",
"unicode": "e878",
"unicode_decimal": 59512
},
{
"icon_id": "43469991",
"name": "关闭",
"font_class": "guanbi1",
"unicode": "e879",
"unicode_decimal": 59513
},
{
"icon_id": "39274830",
"name": "添加",
"font_class": "tianjia1",
"unicode": "e81f",
"unicode_decimal": 59423
},
{
"icon_id": "41007799",
"name": "签到码",
"font_class": "qiandaoma",
"unicode": "e857",
"unicode_decimal": 59479
},
{
"icon_id": "40996457",
"name": "日历",
"font_class": "rili1",
"unicode": "e856",
"unicode_decimal": 59478
},
{
"icon_id": "40820828",
"name": "分组title",
"font_class": "fenzutitle",
"unicode": "e84c",
"unicode_decimal": 59468
},
{
"icon_id": "40820822",
"name": "成功",
"font_class": "chenggong",
"unicode": "e84d",
"unicode_decimal": 59469
},
{
"icon_id": "40820826",
"name": "存草稿",
"font_class": "cuncaogao",
"unicode": "e84e",
"unicode_decimal": 59470
},
{
"icon_id": "40820824",
"name": "图片",
"font_class": "tupian",
"unicode": "e84f",
"unicode_decimal": 59471
},
{
"icon_id": "40820823",
"name": "微信支付",
"font_class": "weixinzhifu1",
"unicode": "e850",
"unicode_decimal": 59472
},
{
"icon_id": "40820821",
"name": "删除",
"font_class": "shanchu",
"unicode": "e851",
"unicode_decimal": 59473
},
{
"icon_id": "40820818",
"name": "钱包",
"font_class": "qianbao",
"unicode": "e852",
"unicode_decimal": 59474
},
{
"icon_id": "40820816",
"name": "男",
"font_class": "nan",
"unicode": "e853",
"unicode_decimal": 59475
},
{
"icon_id": "40820819",
"name": "金币",
"font_class": "jinbi",
"unicode": "e854",
"unicode_decimal": 59476
},
{
"icon_id": "40820817",
"name": "女",
"font_class": "nv",
"unicode": "e855",
"unicode_decimal": 59477
},
{
"icon_id": "40820832",
"name": "分享",
"font_class": "fenxiang1",
"unicode": "e847",
"unicode_decimal": 59463
},
{
"icon_id": "40820825",
"name": "修改",
"font_class": "xiugai",
"unicode": "e848",
"unicode_decimal": 59464
},
{
"icon_id": "40820830",
"name": "编辑",
"font_class": "bianji",
"unicode": "e849",
"unicode_decimal": 59465
},
{
"icon_id": "40820829",
"name": "更多1",
"font_class": "gengduo1",
"unicode": "e84a",
"unicode_decimal": 59466
},
{
"icon_id": "40820820",
"name": "上",
"font_class": "shang",
"unicode": "e84b",
"unicode_decimal": 59467
},
{
"icon_id": "40820837",
"name": "添加",
"font_class": "tianjia",
"unicode": "e840",
"unicode_decimal": 59456
},
{
"icon_id": "40820836",
"name": "更多",
"font_class": "gengduo",
"unicode": "e841",
"unicode_decimal": 59457
},
{
"icon_id": "40820835",
"name": "电话",
"font_class": "dianhua",
"unicode": "e842",
"unicode_decimal": 59458
},
{
"icon_id": "40820827",
"name": "撤销",
"font_class": "chexiao",
"unicode": "e843",
"unicode_decimal": 59459
},
{
"icon_id": "40820831",
"name": "微信",
"font_class": "weixin",
"unicode": "e844",
"unicode_decimal": 59460
},
{
"icon_id": "40820834",
"name": "分组",
"font_class": "fenzu",
"unicode": "e845",
"unicode_decimal": 59461
},
{
"icon_id": "40820833",
"name": "点名",
"font_class": "dianming",
"unicode": "e846",
"unicode_decimal": 59462
},
{
"icon_id": "36759285",
"name": "套餐",
"font_class": "taocan",
"unicode": "e7fc",
"unicode_decimal": 59388
},
{
"icon_id": "33120817",
"name": "特价",
"font_class": "tejia",
"unicode": "e7da",
"unicode_decimal": 59354
},
{
"icon_id": "33120818",
"name": "满减",
"font_class": "manjian",
"unicode": "e7db",
"unicode_decimal": 59355
},
{
"icon_id": "40351403",
"name": "票",
"font_class": "piao",
"unicode": "e83f",
"unicode_decimal": 59455
},
{
"icon_id": "848275",
"name": "勾",
"font_class": "gou",
"unicode": "e63d",
"unicode_decimal": 58941
},
{
"icon_id": "40248532",
"name": "分享",
"font_class": "fenxiang",
"unicode": "e83e",
"unicode_decimal": 59454
},
{
"icon_id": "40216674",
"name": "浏览量",
"font_class": "liulanliang",
"unicode": "e83c",
"unicode_decimal": 59452
},
{
"icon_id": "40216675",
"name": "日历",
"font_class": "rili",
"unicode": "e83d",
"unicode_decimal": 59453
},
{
"icon_id": "40191312",
"name": "左",
"font_class": "zuo",
"unicode": "e83b",
"unicode_decimal": 59451
},
{
"icon_id": "40191242",
"name": "下",
"font_class": "xia",
"unicode": "e83a",
"unicode_decimal": 59450
},
{
"icon_id": "40191226",
"name": "定位",
"font_class": "dingwei",
"unicode": "e82d",
"unicode_decimal": 59437
},
{
"icon_id": "40191227",
"name": "关闭",
"font_class": "guanbi",
"unicode": "e82e",
"unicode_decimal": 59438
},
{
"icon_id": "40191228",
"name": "收藏",
"font_class": "shoucang",
"unicode": "e82f",
"unicode_decimal": 59439
},
{
"icon_id": "40191229",
"name": "时间",
"font_class": "shijian",
"unicode": "e830",
"unicode_decimal": 59440
},
{
"icon_id": "40191230",
"name": "场馆",
"font_class": "changguan",
"unicode": "e831",
"unicode_decimal": 59441
},
{
"icon_id": "40191231",
"name": "未选中",
"font_class": "weixuanzhong",
"unicode": "e832",
"unicode_decimal": 59442
},
{
"icon_id": "40191232",
"name": "选择",
"font_class": "xuanze",
"unicode": "e833",
"unicode_decimal": 59443
},
{
"icon_id": "40191233",
"name": "选择-fill",
"font_class": "xuanze-fill",
"unicode": "e834",
"unicode_decimal": 59444
},
{
"icon_id": "40191234",
"name": "选中",
"font_class": "xuanzhong",
"unicode": "e835",
"unicode_decimal": 59445
},
{
"icon_id": "40191236",
"name": "微信支付",
"font_class": "weixinzhifu",
"unicode": "e836",
"unicode_decimal": 59446
},
{
"icon_id": "40191237",
"name": "右",
"font_class": "you",
"unicode": "e837",
"unicode_decimal": 59447
},
{
"icon_id": "40191238",
"name": "联系我们",
"font_class": "lianxiwomen",
"unicode": "e838",
"unicode_decimal": 59448
},
{
"icon_id": "40191239",
"name": "余额",
"font_class": "yue",
"unicode": "e839",
"unicode_decimal": 59449
}
]
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -55,13 +55,3 @@ a {
::-webkit-scrollbar-track {
background: transparent;
}
.match-ellipsis-single {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-layout .ant-layout-header {
padding-inline: 15px;
}

View File

@ -1,6 +1,9 @@
// src/hooks/useAuth.ts
import { useState } from './useState';
import { watch } from 'vue';
import { useMenuStore } from '@/stores/menuStore';
import { usePermissionStore } from '@/stores/permissionStore';
import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
@ -15,17 +18,42 @@ function createAuth() {
}
});
const login = (newToken: string) => {
const isLoggedIn = () => !!token.value;
/**
* 登录:保存 token +
*/
const login = async (newToken: string) => {
setToken(newToken);
const { loadMenu, homePath } = useMenuStore();
const { loadPermissions } = usePermissionStore();
// 并行拉取菜单和权限
await Promise.all([loadMenu(), loadPermissions()]);
// 跳转到首页(动态路由已注册完成)
router.push(homePath.value);
};
/**
* 退出:清除 token +
*/
const logout = () => {
setToken(null);
const { clearMenu } = useMenuStore();
const { clearPermissions } = usePermissionStore();
clearMenu();
clearPermissions();
router.push('/login');
};
return {
token,
isLoggedIn: () => !!token.value,
isLoggedIn,
login,
logout,
};

View File

@ -1,99 +1,46 @@
import { h } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { routes } from '@/router/routes';
import { generateMenu, type MenuItem } from '@/router/utils';
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
import { useBreadcrumb } from './useBreadcrumb';
/**
* Hook
*
* @description
*
* - generateMenu meta.title / meta.icon
* - /
* - useBreadcrumb route.matched
*/
export function useBasicLayout() {
// 路由实例,用于页面跳转
const router = useRouter();
// 当前路由信息
const route = useRoute();
// 获取面包屑数据
const { breadcrumbItems } = useBreadcrumb();
/**
*
* ("/")
*/
const layoutChildren = routes.find((r) => r.path === '/' && r.children?.length)?.children ?? [];
const menuItems = generateMenu(layoutChildren);
const menuItems = [
{
key: '/events',
icon: h(TrophyOutlined),
label: '我的赛事',
},
// { TODO 本期先做隐藏
// key: '/wallet',
// icon: h(WalletOutlined),
// label: '钱包管理',
// },
];
/**
* key
* submenu selected
*/
const leafMenuKeys = new Set<string>();
const collectLeafKeys = (items: MenuItem[]) => {
items.forEach((item) => {
if (item.children?.length) {
collectLeafKeys(item.children);
} else {
leafMenuKeys.add(item.key);
}
});
};
collectLeafKeys(menuItems);
/**
* key
*
*
* 1. ->
* 2. meta.activeMenu key
* 3.
*/
const getActiveMenuKey = () => {
if (leafMenuKeys.has(route.path)) {
return route.path;
}
for (const r of route.matched) {
const matchedRoutes = route.matched;
for (const r of matchedRoutes) {
if (r.meta?.activeMenu) {
return r.meta.activeMenu as string;
}
}
if (route.path.startsWith('/events')) return '/events';
if (route.path.startsWith('/wallet')) return '/wallet';
return route.path;
};
/**
* keys
*
*/
const getOpenKeys = () => {
return menuItems
.filter(
(item) =>
item.children?.length &&
(route.path === item.key || route.path.startsWith(`${item.key}/`)),
)
.map((item) => item.key);
};
/**
*
*/
const handleMenuClick = (info: { key: string | number }) => {
const handleMenuClick = (info: any) => {
router.push(String(info.key));
};
return {
// 面包屑数据
breadcrumbItems,
// 菜单配置(由路由生成)
menuItems,
// 获取激活菜单 key
getActiveMenuKey,
// 获取展开菜单 keys
getOpenKeys,
// 菜单点击处理
handleMenuClick,
};
}

View File

@ -1,39 +1,28 @@
import { computed, h } from 'vue';
import { useRoute, useRouter } from 'vue-router';
/**
* Hook
*
* @description
* route.matched
* meta.title +
* /
*
* @returns breadcrumbs -
* @returns breadcrumbItems -
*/
export function useBreadcrumb() {
const route = useRoute();
const router = useRouter();
/**
* path
* 使
*
*/
const buildPath = (parentPath: string, recordPath: string): string => {
const buildPath = (currentPath: string, recordPath: string): string => {
if (recordPath.startsWith('/')) {
return recordPath;
}
return `${parentPath}/${recordPath}`.replace(/\/+/g, '/');
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
};
const breadcrumbs = computed(() => {
const items: Array<{ title: string; path: string }> = [];
const matched = route.matched;
const items: Array<{ title: string; path?: string }> = [];
let currentPath = '';
for (const record of route.matched) {
for (const record of matched) {
currentPath = buildPath(currentPath, record.path);
if (record.meta?.title) {
if (record.meta && record.meta.title) {
items.push({
title: record.meta.title as string,
path: currentPath,
@ -49,19 +38,25 @@ export function useBreadcrumb() {
key: index,
title:
item.path && index !== breadcrumbs.value.length - 1
? // 非末项:渲染为可点击链接
h(
? h(
'a',
{
onClick: (e: Event) => {
e.preventDefault();
router.push(item.path);
// 最保守方案:只有当前路径包含 /bracket/ 且目标路径是 /events/bracket 时才保留 query
// 也就是只有:/events/bracket/xxx → /events/bracket 时才保留
const targetPath = item.path || '/';
const isBracketChildPage =
route.path.includes('/bracket/') && targetPath === '/events/bracket';
router.push({
path: targetPath,
query: isBracketChildPage ? route.query : {},
});
},
},
item.title,
)
: // 末项:仅显示文本,表示当前所在位置
item.title,
: item.title,
}));
});

View File

@ -1,200 +1,57 @@
.container {
min-height: 100vh;
background: #f0f2f5;
}
.sider {
background: #fff !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
:deep(.ant-menu) {
background: #fff;
border-right: 1px solid #f0f0f0;
}
:deep(.ant-menu-item) {
margin: 0;
border-radius: 0;
color: #595959;
&:hover {
background: #e6f7ff;
color: #1890ff;
}
}
:deep(.ant-menu-item-selected) {
position: relative;
background: linear-gradient(90deg, #e6f7ff 0%, #bae7ff 100%) !important;
color: #1890ff !important;
font-weight: 500;
// 左侧高亮指示条,标识当前选中的菜单项
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #1890ff;
}
}
:deep(.ant-menu-submenu-title) {
margin: 0;
color: #595959;
&:hover {
color: #1890ff;
}
}
:deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
color: #1890ff !important;
}
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
}
.logo {
height: 64px;
height: 56px;
display: flex;
align-items: center;
padding: 0 24px;
justify-content: center;
overflow: hidden;
color: #262626;
background: #fff;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.logoIcon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
border-radius: 8px;
flex-shrink: 0;
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.logoText {
font-size: 18px;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
color: #262626;
}
.header {
background: #ffffff !important;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
border-bottom: 1px solid #f0f0f0;
:deep(.ant-layout-header) {
background: #ffffff !important;
}
}
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.headerRight {
background: #fff;
padding: 0 16px;
display: flex;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 8px;
padding: 0 12px;
display: inline-flex;
align-items: center;
color: #595959;
border-radius: 4px;
&:hover {
color: #1890ff;
background: #f5f5f5;
color: #1677ff;
}
}
.refreshIcon {
font-size: 16px;
cursor: pointer;
padding: 8px;
display: inline-flex;
align-items: center;
color: #595959;
border-radius: 4px;
&:hover {
color: #1890ff;
background: #f5f5f5;
}
}
.breadcrumb {
margin-left: 12px;
color: #8c8c8c;
:deep(.ant-breadcrumb-link) {
color: #8c8c8c;
&:hover {
color: #1890ff;
}
}
:deep(.ant-breadcrumb-last) {
color: #262626;
}
}
.headerIcon {
font-size: 16px;
cursor: pointer;
color: #595959;
padding: 8px;
border-radius: 4px;
transition: all 0.3s;
&:hover {
color: #1890ff;
background: #f5f5f5;
}
}
.userInfo {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
transition: background 0.3s;
// &:hover {
// background: #f5f5f5;
// }
}
.userName {
color: #262626;
font-size: 14px;
}
.content {
margin: 0;
background: #f0f2f5;
min-height: calc(100vh - 64px);
overflow: auto;
margin: 16px;
padding: 24px;
background: #fff;
border-radius: 8px;
min-height: 280px;
}
.pageContent {
padding: 15px;
.footer {
text-align: center;
color: rgba(0, 0, 0, 0.45);
}

View File

@ -1,171 +1,105 @@
import { computed, defineComponent, ref } from 'vue';
import { Layout, Menu, Breadcrumb, Modal, Avatar, Space } from 'ant-design-vue';
import { RouterView, useRouter } from 'vue-router';
import { computed, defineComponent, h, ref } from 'vue';
import { useRouter, useRoute, RouterView } from 'vue-router';
import { Layout, Menu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import {
DashboardOutlined,
InfoCircleOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ReloadOutlined,
SearchOutlined,
BellOutlined,
FullscreenOutlined,
UserOutlined,
SettingOutlined,
} from '@ant-design/icons-vue';
import { useBasicLayout } from '../hooks/useBasicLayout';
import { auth } from '@/hooks/useAuth';
import { useMenuStore } from '@/stores/menuStore';
import styles from './BasicLayout.module.less';
// 解构 Layout 组件
const { Header, Sider, Content } = Layout;
const { Header, Sider, Content, Footer } = Layout;
/**
*
*
* @description
* CPMS
* - Logo +
* - Header + +
* -
*
* @example
* ```typescript
* // 在路由中使用
* {
* path: '/',
* component: BasicLayout,
* children: [...]
* }
* ```
*
* icon antd icon
*
*/
const ICON_MAP: Record<string, any> = {
DashboardOutlined,
InfoCircleOutlined,
SettingOutlined,
};
/**
* menuStore menuItems antd Menu items
* iconName icon VNode
*/
function resolveMenuItems(items: any[]): MenuProps['items'] {
return items.map((item) => {
const resolved: any = {
key: item.key,
label: item.label,
};
// iconName 字符串转为 VNode
if (item.iconName && ICON_MAP[item.iconName]) {
resolved.icon = () => h(ICON_MAP[item.iconName]);
}
if (item.children?.length) {
resolved.children = resolveMenuItems(item.children);
}
return resolved;
});
}
/**
* 基础布局:左侧导航() + + +
*/
export default defineComponent({
name: 'BasicLayout',
setup() {
// 使用布局 Hook 获取菜单和面包屑数据
const { breadcrumbItems, menuItems, getActiveMenuKey, getOpenKeys, handleMenuClick } =
useBasicLayout();
// 路由实例,用于退出登录后跳转到登录页
const route = useRoute();
const router = useRouter();
/**
*
* true:
* false: +
*/
const collapsed = ref(false);
/**
* /
*/
const { menuItems } = useMenuStore();
const selectedKeys = computed<string[]>(() => [route.path]);
const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
router.push(key as string);
};
const toggleCollapsed = () => {
collapsed.value = !collapsed.value;
};
/**
* 退
* -
* - ->
* - ->
*/
const handleLogout = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出当前账号吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
// 清除本地登录态(不调用接口),否则路由守卫会把 /login 重定向回首页
auth.logout();
router.replace('/login');
},
});
};
return () => (
<Layout class={styles.container}>
{/* 左侧侧边栏 */}
<Sider
class={styles.sider}
collapsed={collapsed.value}
trigger={null}
collapsible
width={220}
>
{/* Logo 区域 */}
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
<div class={styles.logo}>
<div class={styles.logoIcon} />
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS运营平台'}</span>
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六个羽友赛事'}</span>
</div>
{/* 导航菜单 */}
<Menu
theme="light"
theme="dark"
mode="inline"
selectedKeys={[getActiveMenuKey()]}
openKeys={getOpenKeys()}
items={menuItems}
selectedKeys={selectedKeys.value}
items={resolvedItems.value}
onClick={handleMenuClick}
/>
</Sider>
{/* 右侧内容区域 */}
<Layout>
{/* 顶部 Header */}
<Header class={styles.header} style={{ background: '#ffffff' }}>
<div class={styles.headerLeft}>
{/* 折叠/展开按钮 */}
<span class={styles.trigger} onClick={toggleCollapsed}>
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
{/* 刷新按钮 */}
{/* <span class={styles.refreshIcon}>
<ReloadOutlined />
</span> */}
{/* 面包屑导航 */}
{breadcrumbItems.value.length > 0 && (
<Breadcrumb class={styles.breadcrumb}>
{breadcrumbItems.value.map((item) => (
<Breadcrumb.Item key={item.key}>{item.title}</Breadcrumb.Item>
))}
</Breadcrumb>
)}
</div>
{/* Header 右侧:用户信息 */}
<div class={styles.headerRight}>
<Space size="middle">
{/* 搜索图标 */}
{/* <span class={styles.headerIcon}>
<SearchOutlined />
</span> */}
{/* 通知图标 */}
{/* <span class={styles.headerIcon}>
<BellOutlined />
</span> */}
{/* 全屏图标 */}
{/* <span class={styles.headerIcon}>
<FullscreenOutlined />
</span> */}
{/* 用户信息:点击弹出退出登录确认 */}
<div class={styles.userInfo} onClick={handleLogout}>
<Avatar size="small" icon={<UserOutlined />} />
<span class={styles.userName}></span>
</div>
</Space>
</div>
<Header class={styles.header}>
<span class={styles.trigger} onClick={toggleCollapsed}>
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</Header>
{/* 内容区域 */}
<Content class={styles.content}>
<div class={styles.pageContent}>
{/* 路由视图:根据当前路由渲染对应页面 */}
<RouterView />
</div>
<RouterView />
</Content>
<Footer class={styles.footer}> ©{new Date().getFullYear()}</Footer>
</Layout>
</Layout>
);

View File

@ -4,8 +4,8 @@ import 'ant-design-vue/dist/reset.css';
import App from './App';
import router from './router';
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
import './assets/styles/index.less';
import './assets/iconfont/iconfont.css';
const app = createApp(App);

View File

@ -1,6 +1,6 @@
import { defineComponent } from 'vue';
import { Card } from 'ant-design-vue';
import styles from './About.module.less';
import styles from './index.module.less';
interface TechItem {
name: string;

View File

@ -0,0 +1,27 @@
.container {
// 容器
}
.title {
margin-top: 0;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
}
.panel {
margin-top: 16px;
ul {
margin: 12px 0 0;
padding-left: 20px;
line-height: 2;
}
code {
padding: 2px 8px;
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
}
}

View File

@ -0,0 +1,67 @@
import { defineComponent } from 'vue';
import { Card, Col, Row, Statistic } from 'ant-design-vue';
import styles from './index.module.less';
interface StatItem {
title: string;
value: number;
suffix: string;
precision?: number;
}
/**
* /
*/
export default defineComponent({
name: 'Dashboard',
setup() {
const stats: StatItem[] = [
{ title: '今日访问', value: 1280, suffix: '次' },
{ title: '活跃用户', value: 368, suffix: '人' },
{ title: '订单总数', value: 9920, suffix: '单' },
{ title: '处理率', value: 96.8, suffix: '%', precision: 1 },
];
return () => (
<div class={styles.container}>
<h2 class={styles.title}></h2>
<Row gutter={[16, 16]}>
{stats.map((item) => (
<Col key={item.title} xs={24} sm={12} lg={6}>
<Card>
<Statistic
title={item.title}
value={item.value}
suffix={item.suffix}
precision={item.precision}
/>
</Card>
</Col>
))}
</Row>
<Card class={styles.panel} title="项目说明">
<p>
使 CPMS Vue 3 + Vite + TypeScript + Ant Design Vue
, TSX ,
</p>
<ul>
<li>
:<code>npm run dev</code>
</li>
<li>
():<code>npm run build:test</code>
</li>
<li>
():<code>npm run build:prod</code>
</li>
<li>
:<code>npm run lint</code>
</li>
</ul>
</Card>
</div>
);
},
});

View File

@ -1,20 +1,806 @@
// ── 登录页 — 左右分栏 — 动画背景版 ──
// 左侧:深色品牌展示区,含浮动光球、网格呼吸、脉冲涟漪、粒子点阵等多层动画
// 右侧:白色登录表单区,入场渐显动画
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
.card {
width: 400px;
max-width: 90vw;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
// ━━━━ 左侧品牌展示区 ━━━━
.brand {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: linear-gradient(160deg, #0c1e3a 0%, #132744 50%, #0a1628 100%);
color: #fff;
overflow: hidden;
padding: 40px 56px 40px 64px;
}
:global(.ant-card-head-title) {
text-align: center;
font-size: 18px;
font-weight: 600;
// ── 动画层1: 浮动光球 ──
.orb1 {
position: absolute;
width: 460px;
height: 460px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(22, 119, 255, 0.14) 0%,
rgba(22, 119, 255, 0.04) 40%,
transparent 70%
);
top: -100px;
right: 20%;
z-index: 0;
animation: orbFloat1 12s ease-in-out infinite;
filter: blur(2px);
}
.orb2 {
position: absolute;
width: 360px;
height: 360px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(82, 196, 26, 0.1) 0%,
rgba(82, 196, 26, 0.03) 40%,
transparent 70%
);
bottom: 40px;
left: 10%;
z-index: 0;
animation: orbFloat2 15s ease-in-out infinite;
filter: blur(2px);
}
.orb3 {
position: absolute;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(250, 173, 20, 0.08) 0%,
rgba(250, 173, 20, 0.02) 40%,
transparent 70%
);
top: 50%;
left: 55%;
z-index: 0;
animation: orbFloat3 18s ease-in-out infinite;
filter: blur(3px);
}
// ── 动画层2: 脉冲涟漪 ──
.pulse {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
border: 1px solid rgba(22, 119, 255, 0.15);
top: 30%;
right: 15%;
z-index: 0;
animation: pulseExpand 4s ease-out infinite;
&::before {
content: '';
position: absolute;
inset: 30px;
border-radius: 50%;
border: 1px solid rgba(22, 119, 255, 0.08);
animation: pulseExpand 4s ease-out infinite 1s;
}
&::after {
content: '';
position: absolute;
inset: 60px;
border-radius: 50%;
border: 1px solid rgba(22, 119, 255, 0.04);
animation: pulseExpand 4s ease-out infinite 2s;
}
}
// ── 动画层3: 网格呼吸 ──
.brandGrid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-image:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridBreathe 6s ease-in-out infinite;
}
// ── 动画层4: 粒子点阵 ──
.particles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.particle {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(22, 119, 255, 0.5);
animation: particleFloat 8s ease-in-out infinite;
&::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
border-radius: 50%;
background: rgba(105, 177, 255, 0.3);
top: -8px;
left: 3px;
animation: particleFloat 6s ease-in-out infinite reverse;
}
}
// ── 动画层5: 扫描线 ──
.scanLine {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(22, 119, 255, 0.3), transparent);
z-index: 1;
animation: scanDown 8s linear infinite;
}
// ━━━━ 动画定义 ━━━━
@keyframes orbFloat1 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(30px, 20px) scale(1.05);
}
50% {
transform: translate(-20px, 40px) scale(0.95);
}
75% {
transform: translate(10px, -30px) scale(1.02);
}
}
@keyframes orbFloat2 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(-40px, -20px) scale(1.08);
}
66% {
transform: translate(30px, 30px) scale(0.92);
}
}
@keyframes orbFloat3 {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-50px, -40px);
}
}
@keyframes pulseExpand {
0% {
transform: scale(0.8);
opacity: 0.6;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@keyframes gridBreathe {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
@keyframes particleFloat {
0%,
100% {
transform: translateY(0) translateX(0);
opacity: 0.5;
}
25% {
transform: translateY(-30px) translateX(10px);
opacity: 1;
}
50% {
transform: translateY(-60px) translateX(-5px);
opacity: 0.3;
}
75% {
transform: translateY(-30px) translateX(15px);
opacity: 0.8;
}
}
@keyframes scanDown {
0% {
top: -2px;
}
100% {
top: 100%;
}
}
// ━━━━ 内容层 ━━━━
.brandInner {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
flex: 1;
animation: fadeInUp 0.8s ease-out both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ── Logo ──
.logoRow {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 48px;
}
.logoMark {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, #1677ff, #69b1ff);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
color: #fff;
box-shadow: 0 4px 20px rgba(22, 119, 255, 0.45);
animation: logoPulse 3s ease-in-out infinite;
position: relative;
&::after {
content: '';
position: absolute;
top: 4px;
left: 4px;
width: 18px;
height: 7px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.3);
}
}
@keyframes logoPulse {
0%,
100% {
box-shadow: 0 4px 20px rgba(22, 119, 255, 0.45);
}
50% {
box-shadow:
0 4px 30px rgba(22, 119, 255, 0.7),
0 0 60px rgba(22, 119, 255, 0.2);
}
}
.logoText {
font-size: 18px;
font-weight: 600;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.9);
}
// ── 大标题 ──
.brandTitle {
font-size: 32px;
font-weight: 700;
line-height: 1.35;
letter-spacing: 1px;
margin-bottom: 12px;
animation: fadeInUp 0.8s ease-out 0.1s both;
}
.brandSubtitle {
font-size: 15px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 40px;
max-width: 400px;
animation: fadeInUp 0.8s ease-out 0.2s both;
}
// ── 数据统计条 ──
.statsRow {
display: flex;
gap: 36px;
margin-bottom: 44px;
animation: fadeInUp 0.8s ease-out 0.3s both;
}
.statItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.statValue {
font-size: 28px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.statLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 1px;
}
.statHighlight {
color: #69b1ff;
}
// ── 赛事模拟卡片 ──
.eventCards {
display: flex;
gap: 16px;
margin-bottom: 44px;
animation: fadeInUp 0.8s ease-out 0.4s both;
}
.eventCard {
flex: 1;
padding: 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
backdrop-filter: blur(8px);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
// 内部光效装饰
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 30%, rgba(22, 119, 255, 0.06) 0%, transparent 50%);
animation: cardShimmer 5s linear infinite;
pointer-events: none;
}
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(22, 119, 255, 0.3);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(22, 119, 255, 0.15);
}
}
@keyframes cardShimmer {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.eventIcon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
position: relative;
z-index: 1;
&.blue {
background: linear-gradient(135deg, #1677ff, #4096ff);
box-shadow: 0 2px 12px rgba(22, 119, 255, 0.3);
}
&.green {
background: linear-gradient(135deg, #52c41a, #73d13d);
box-shadow: 0 2px 12px rgba(82, 196, 26, 0.3);
}
&.orange {
background: linear-gradient(135deg, #fa8c16, #ffa940);
box-shadow: 0 2px 12px rgba(250, 140, 22, 0.3);
}
}
.eventTitle {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
position: relative;
z-index: 1;
}
.eventDesc {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.5;
position: relative;
z-index: 1;
}
// ── 步骤流程 ──
.flowSteps {
display: flex;
align-items: center;
gap: 0;
animation: fadeInUp 0.8s ease-out 0.5s both;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
// ── 星点效果 ──
// 中心发光点 + 四条十字光芒 + 缓慢旋转 + 闪烁呼吸
.stepDot {
position: relative;
width: 20px;
height: 20px;
margin-bottom: 12px;
// 中心核心圆点
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
box-shadow:
0 0 4px 2px rgba(22, 119, 255, 0.8),
0 0 12px 4px rgba(22, 119, 255, 0.5),
0 0 24px 8px rgba(22, 119, 255, 0.2);
transform: translate(-50%, -50%);
animation: starCorePulse 2s ease-in-out infinite;
}
// 十字光芒线(横+纵)
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
transform: translate(-50%, -50%) rotate(45deg);
animation:
starSpin 6s linear infinite,
starGlowPulse 2s ease-in-out infinite;
// 横向光芒
background:
linear-gradient(
0deg,
transparent 45%,
rgba(22, 119, 255, 0.9) 48%,
#fff 50%,
rgba(22, 119, 255, 0.9) 52%,
transparent 55%
),
linear-gradient(
90deg,
transparent 45%,
rgba(22, 119, 255, 0.9) 48%,
#fff 50%,
rgba(22, 119, 255, 0.9) 52%,
transparent 55%
);
}
}
// 步骤之间的连线
.stepLine {
flex: 1;
height: 1px;
min-width: 20px;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.5), rgba(22, 119, 255, 0.15));
align-self: center;
margin-top: -6px; // 微调与星点中心对齐(因为label占空间)
}
@keyframes starCorePulse {
0%,
100% {
box-shadow:
0 0 4px 2px rgba(22, 119, 255, 0.8),
0 0 12px 4px rgba(22, 119, 255, 0.5),
0 0 24px 8px rgba(22, 119, 255, 0.2);
}
50% {
box-shadow:
0 0 6px 3px rgba(22, 119, 255, 1),
0 0 20px 6px rgba(22, 119, 255, 0.7),
0 0 40px 12px rgba(22, 119, 255, 0.3);
}
}
@keyframes starSpin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes starGlowPulse {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
.stepLabel {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
text-align: center;
}
// ── 左侧底部版权 ──
.brandFooter {
margin-top: auto;
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 1px;
}
// ━━━━ 右侧登录区 ━━━━
.loginSide {
width: 460px;
min-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 48px;
background: #fff;
position: relative;
}
.loginWrapper {
width: 100%;
max-width: 340px;
animation: fadeInRight 0.6s ease-out 0.3s both;
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.loginTitle {
font-size: 24px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 6px;
}
.loginDesc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 36px;
}
.loginForm {
:global(.ant-input-affix-wrapper) {
padding: 10px 14px;
border-radius: 10px;
border-color: #d9d9d9;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
}
&:focus-within {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
}
}
:global(.ant-input-prefix) {
color: rgba(0, 0, 0, 0.3);
margin-right: 8px;
}
:global(.ant-form-item) {
margin-bottom: 22px;
}
}
.loginBtn {
height: 46px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: 3px;
transition: all 0.3s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.35);
}
}
// ── 记住密码行 ──
.rememberRow {
margin-bottom: 8px;
:global(.ant-checkbox-wrapper) {
color: rgba(0, 0, 0, 0.55);
font-size: 13px;
}
:global(.ant-checkbox-checked .ant-checkbox-inner) {
background-color: #1677ff;
border-color: #1677ff;
}
}
.loginFooter {
margin-top: 28px;
text-align: center;
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
}
// ━━━━ 响应式 — PC 端自适应 ━━━━
// ── 大屏 (>1200px): 默认样式,无需额外处理 ──
// ── 中屏 (≤1200px): 缩减间距、光球尺寸、字号 ──
@media screen and (max-width: 1200px) {
.brand {
padding: 32px 36px 32px 40px;
}
.brandTitle {
font-size: 28px;
}
.brandSubtitle {
font-size: 14px;
margin-bottom: 32px;
}
.statsRow {
gap: 24px;
margin-bottom: 32px;
}
.statValue {
font-size: 24px;
}
.eventCards {
gap: 12px;
margin-bottom: 32px;
}
.eventCard {
padding: 16px;
}
.eventIcon {
width: 32px;
height: 32px;
margin-bottom: 10px;
}
.eventTitle {
font-size: 14px;
}
.eventDesc {
font-size: 11px;
}
.orb1 {
width: 360px;
height: 360px;
}
.orb2 {
width: 280px;
height: 280px;
}
.orb3 {
width: 200px;
height: 200px;
}
.loginSide {
width: 400px;
min-width: 340px;
padding: 36px 40px;
}
.loginWrapper {
max-width: 320px;
}
.loginTitle {
font-size: 22px;
}
.loginDesc {
font-size: 13px;
}
.stepLabel {
font-size: 12px;
}
}

View File

@ -1,8 +1,8 @@
import { defineComponent, reactive, ref } from 'vue';
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
import { Button, Checkbox, Form, FormItem, Input, message } from 'ant-design-vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { auth } from '@/hooks/useAuth';
import { useEffect } from '@/hooks/useEffect';
import styles from './index.module.less';
interface LoginForm {
@ -10,20 +10,31 @@ interface LoginForm {
password: string;
}
/**
*
*/
export default defineComponent({
name: 'LoginPage',
setup() {
const router = useRouter();
const loading = ref(false);
const rememberMe = ref(false);
const form = reactive<LoginForm>({
username: 'admin',
password: '123456',
username: '',
password: '',
});
useEffect(() => {
const saved = localStorage.getItem('cpms_login_remember');
if (saved) {
try {
const data = JSON.parse(saved);
form.username = data.username || '';
form.password = data.password || '';
rememberMe.value = true;
} catch {
localStorage.removeItem('cpms_login_remember');
}
}
}, []);
const rules = {
username: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
@ -37,46 +48,197 @@ export default defineComponent({
loading.value = true;
// TODO: 替换为真实登录接口
// const res = await post<LoginResult>('/login', { ...form });
// auth.login(res.token);
setTimeout(() => {
auth.login('mock_token_' + Date.now());
try {
await new Promise((r) => setTimeout(r, 600));
await auth.login('mock_token_' + Date.now());
message.success('登录成功');
if (rememberMe.value) {
localStorage.setItem(
'cpms_login_remember',
JSON.stringify({
username: form.username,
password: form.password,
}),
);
} else {
localStorage.removeItem('cpms_login_remember');
}
} catch (err) {
message.error('登录失败,请重试');
} finally {
loading.value = false;
router.push('/dashboard');
}, 600);
}
};
const particles = [
{ top: '8%', left: '15%', delay: '0s', duration: '8s' },
{ top: '22%', left: '65%', delay: '1.5s', duration: '7s' },
{ top: '45%', left: '25%', delay: '0.8s', duration: '9s' },
{ top: '60%', left: '75%', delay: '2s', duration: '6.5s' },
{ top: '75%', left: '40%', delay: '3s', duration: '8.5s' },
{ top: '15%', left: '80%', delay: '1s', duration: '7.5s' },
{ top: '35%', left: '50%', delay: '2.5s', duration: '6s' },
{ top: '85%', left: '20%', delay: '0.5s', duration: '9.5s' },
{ top: '50%', left: '90%', delay: '3.5s', duration: '8s' },
{ top: '92%', left: '55%', delay: '1.2s', duration: '7s' },
];
return () => (
<div class={styles.container}>
<Card class={styles.card} title="CPMS 运营平台">
<Form model={form} rules={rules} layout="vertical" onFinish={handleSubmit}>
<FormItem name="username">
<Input
v-model={[form.username, 'value']}
placeholder="用户名"
size="large"
v-slots={{ prefix: () => <UserOutlined /> }}
<div class={styles.brand}>
<div class={styles.orb1} />
<div class={styles.orb2} />
<div class={styles.orb3} />
<div class={styles.pulse} />
<div class={styles.brandGrid} />
<div class={styles.scanLine} />
<div class={styles.particles}>
{particles.map((p, i) => (
<div
key={i}
class={styles.particle}
style={{
top: p.top,
left: p.left,
animationDelay: p.delay,
animationDuration: p.duration,
}}
/>
</FormItem>
<FormItem name="password">
<Input
v-model={[form.password, 'value']}
type="password"
placeholder="密码"
size="large"
v-slots={{ prefix: () => <LockOutlined /> }}
/>
</FormItem>
<FormItem>
<Button type="primary" html-type="submit" size="large" block loading={loading.value}>
</Button>
</FormItem>
</Form>
</Card>
))}
</div>
<div class={styles.brandInner}>
<div class={styles.logoRow}>
<div class={styles.logoMark}>6</div>
<span class={styles.logoText}></span>
</div>
<h1 class={styles.brandTitle}></h1>
<p class={styles.brandSubtitle}>
</p>
<div class={styles.statsRow}>
<div class={styles.statItem}>
<span class={styles.statValue}>
<span class={styles.statHighlight}>1,200</span>+
</span>
<span class={styles.statLabel}></span>
</div>
<div class={styles.statItem}>
<span class={styles.statValue}>
<span class={styles.statHighlight}>50,000</span>+
</span>
<span class={styles.statLabel}></span>
</div>
<div class={styles.statItem}>
<span class={styles.statValue}>
<span class={styles.statHighlight}>100</span>%
</span>
<span class={styles.statLabel}></span>
</div>
</div>
<div class={styles.eventCards}>
<div class={styles.eventCard}>
<div class={[styles.eventIcon, styles.blue]}>📋</div>
<div class={styles.eventTitle}></div>
<div class={styles.eventDesc}>线</div>
</div>
<div class={styles.eventCard}>
<div class={[styles.eventIcon, styles.green]}>🏆</div>
<div class={styles.eventTitle}></div>
<div class={styles.eventDesc}></div>
</div>
<div class={styles.eventCard}>
<div class={[styles.eventIcon, styles.orange]}>📊</div>
<div class={styles.eventTitle}></div>
<div class={styles.eventDesc}></div>
</div>
</div>
<div class={styles.flowSteps}>
<div class={styles.step}>
<span class={styles.stepDot} />
<span class={styles.stepLabel}></span>
</div>
<div class={styles.stepLine} />
<div class={styles.step}>
<span class={styles.stepDot} />
<span class={styles.stepLabel}></span>
</div>
<div class={styles.stepLine} />
<div class={styles.step}>
<span class={styles.stepDot} />
<span class={styles.stepLabel}></span>
</div>
<div class={styles.stepLine} />
<div class={styles.step}>
<span class={styles.stepDot} />
<span class={styles.stepLabel}></span>
</div>
<div class={styles.stepLine} />
<div class={styles.step}>
<span class={styles.stepDot} />
<span class={styles.stepLabel}></span>
</div>
</div>
<div class={styles.brandFooter}> ©{new Date().getFullYear()}</div>
</div>
</div>
<div class={styles.loginSide}>
<div class={styles.loginWrapper}>
<h2 class={styles.loginTitle}></h2>
<p class={styles.loginDesc}></p>
<Form
class={styles.loginForm}
model={form}
rules={rules}
layout="vertical"
onFinish={handleSubmit}
>
<FormItem name="username">
<Input
v-model={[form.username, 'value']}
placeholder="请输入用户名"
size="large"
v-slots={{ prefix: () => <UserOutlined /> }}
/>
</FormItem>
<FormItem name="password">
<Input
v-model={[form.password, 'value']}
type="password"
placeholder="请输入密码"
size="large"
v-slots={{ prefix: () => <LockOutlined /> }}
/>
</FormItem>
{/* 记住密码 */}
<div class={styles.rememberRow}>
<Checkbox v-model={[rememberMe.value, 'checked']}></Checkbox>
</div>
<FormItem>
<Button
class={styles.loginBtn}
type="primary"
htmlType="submit"
size="large"
block
loading={loading.value}
>
</Button>
</FormItem>
</Form>
<div class={styles.loginFooter}> ©{new Date().getFullYear()}</div>
</div>
</div>
</div>
);
},

View File

@ -10,7 +10,7 @@ export default defineComponent({
setup() {
const router = useRouter();
const goHome = () => router.push('/');
const goHome = () => router.push('/login');
return () => (
<Result

14
src/router/glob.ts Normal file
View File

@ -0,0 +1,14 @@
// @ts-nocheck
/**
* Vite import.meta.glob
*
* TS import.meta.glob glob ( **),
*
* Vite glob import
*
* : { '/src/pages/dashboard/index.tsx': () => Promise<Module> }
* src/pages/ index.tsx,
*/
const pageModules = import.meta.glob('/src/pages/**/index.tsx');
export { pageModules };

53
src/router/guard.ts Normal file
View File

@ -0,0 +1,53 @@
import router from '@/router';
import { auth } from '@/hooks/useAuth';
/**
*
* :
* 1. /login ,
* 2. /login
* 3. + ()
* 4.
*
* 关键: loadMenu , next({ path, replace: true })
* ,使, 404
* 注意:不能用 next({ ...to, replace: true }), to
* matched , /:pathMatch(.*)*
*/
router.beforeEach(async (to, _from, next) => {
// 设置页面标题
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
// ── /login 特殊处理 ──
if (to.path === '/login') {
if (auth.isLoggedIn()) {
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
next(homePath.value);
} else {
next();
}
return;
}
// ── 未登录 → 跳转登录页 ──
if (!auth.isLoggedIn()) {
next('/login');
return;
}
// ── 已登录:确保菜单和动态路由已加载 ──
const { loadMenu, loaded } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
if (!loaded.value) {
const { loadPermissions } = await import('@/stores/permissionStore').then((m) =>
m.usePermissionStore(),
);
await Promise.all([loadMenu(), loadPermissions()]);
// 动态路由刚注册,需要用新路由表重新匹配当前路径
next({ path: to.fullPath, replace: true });
return;
}
next();
});

View File

@ -1,40 +1,37 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { routes } from './routes';
import { auth } from '@/hooks/useAuth';
import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './routes';
const router = createRouter({
history: createWebHashHistory(import.meta.env.VITE_BASE_URL || '/'),
routes,
history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'),
routes: constantRoutes,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
// 路由守卫:登录鉴权 + 页面标题
router.beforeEach((to, _from, next) => {
const isLoggedIn = auth.isLoggedIn();
/**
* 路由白名单:不需要登录即可访问的路径
*/
const WHITE_LIST = ['/login', '/404'];
// 设置页面标题
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
/**
* ( resetRouter )
*/
const STATIC_ROUTE_NAMES = new Set(['Login', 'NotFound']);
// 如果访问的是登录页
if (to.path === '/login') {
if (isLoggedIn) {
// 已登录则重定向到首页
next('/dashboard');
} else {
// 未登录则允许访问登录页
next();
/**
* 重置路由:清除动态添加的路由( BasicLayout ),
* 退
*/
export function resetRouter() {
const currentRoutes = router.getRoutes();
currentRoutes.forEach((route) => {
const routeName = route.name as string;
// 保留静态路由(Login, NotFound),移除所有动态路由(含 BasicLayout 及其子路由)
if (routeName && !STATIC_ROUTE_NAMES.has(routeName)) {
router.removeRoute(routeName);
}
} else {
// 访问其他页面
if (isLoggedIn) {
// 已登录则允许访问
next();
} else {
// 未登录则重定向到登录页
next('/login');
}
}
});
});
}
export default router;
export { WHITE_LIST };

View File

@ -1,183 +1,30 @@
import type { RouteRecordRaw } from 'vue-router';
import {
BarChartOutlined,
UserOutlined,
StarOutlined,
FileTextOutlined,
NotificationOutlined,
BorderTopOutlined,
BoxPlotOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
import { createRoute } from './utils';
export const routes: RouteRecordRaw[] = [
/**
* ====== () ======
* 404
* menuStore router.addRoute ,
* MenuNode component ,
* import.meta.glob src/pages
*
* 注意: BasicLayout , menuStore ,
* children
*/
export const constantRoutes: RouteRecordRaw[] = [
// ── 登录页(不经过布局)──
{
path: '/login',
name: 'Login',
component: () => import('@/pages/login'),
meta: { title: '登录', hideInMenu: true },
},
// ── 主布局(需要登录)──
{
path: '/',
component: () => import('@/layouts/BasicLayout'),
children: [
// 数据分析
createRoute('/dashboard', () => import('@/views/Dashboard'), {
title: '数据分析',
icon: BarChartOutlined,
}),
// 场馆列表(分组)
{
path: '/venue',
name: 'Venue',
redirect: '/venue/list',
meta: {
title: '场馆列表',
icon: 'icon-changguan1',
activeMenu: '/venue',
},
children: [
createRoute('/venue/list', () => import('@/views/venue/list'), {
title: '馆客多',
}),
createRoute('/venue/records', () => import('@/views/venue/records'), {
title: '收录',
}),
],
},
// C端用户列表
createRoute('/users', () => import('@/views/Dashboard'), {
title: 'C端用户列表',
icon: UserOutlined,
}),
// 点评管理
createRoute('/reviews', () => import('@/views/Dashboard'), {
title: '点评管理',
icon: StarOutlined,
}),
// 广告位管理
createRoute('/ads', () => import('@/views/Dashboard'), {
title: '广告位管理',
icon: NotificationOutlined,
}),
// 资讯管理
createRoute('/news', () => import('@/views/Dashboard'), {
title: '资讯管理',
icon: FileTextOutlined,
}),
// 申请入驻管理
createRoute('/apply', () => import('@/views/Dashboard'), {
title: '申请入驻管理',
icon: UserOutlined,
}),
// 反馈管理
createRoute('/feedback', () => import('@/views/Dashboard'), {
title: '反馈管理',
icon: BoxPlotOutlined,
}),
// 活动管理(分组)
{
path: '/activity',
name: 'Activity',
redirect: '/activity/list',
meta: { title: '活动管理', icon: 'icon-rili', activeMenu: '/activity' },
children: [
createRoute('/activity/list', () => import('@/views/Dashboard'), {
title: '活动列表',
}),
createRoute('/activity/categories', () => import('@/views/Dashboard'), {
title: '活动分类',
}),
],
},
// 场馆设置(分组)
{
path: '/venue-settings',
name: 'VenueSettings',
redirect: '/venue-settings/basic',
meta: {
title: '场馆设置',
icon: BorderTopOutlined,
activeMenu: '/venue-settings',
},
children: [
createRoute('/venue-settings/basic', () => import('@/views/Dashboard'), {
title: '基本设置',
}),
createRoute('/venue-settings/service', () => import('@/views/Dashboard'), {
title: '服务设置',
}),
],
},
// 钱包管理(分组)
{
path: '/wallet',
name: 'Wallet',
redirect: '/wallet/records',
meta: { title: '钱包管理', icon: 'icon-qianbao', activeMenu: '/wallet' },
children: [
createRoute('/wallet/records', () => import('@/views/Dashboard'), {
title: '交易记录',
}),
createRoute('/wallet/withdraw', () => import('@/views/Dashboard'), {
title: '提现管理',
}),
createRoute('/wallet/recharge', () => import('@/views/Dashboard'), {
title: '充值管理',
}),
],
},
// 系统管理(分组)
{
path: '/system',
name: 'System',
redirect: '/system/users',
meta: {
title: '系统管理',
icon: SafetyCertificateOutlined,
activeMenu: '/system',
},
children: [
createRoute('/system/users', () => import('@/views/Dashboard'), {
title: '用户管理',
}),
createRoute('/system/roles', () => import('@/views/Dashboard'), {
title: '角色管理',
}),
createRoute('/system/permissions', () => import('@/views/Dashboard'), {
title: '权限管理',
}),
createRoute('/system/logs', () => import('@/views/Dashboard'), {
title: '操作日志',
}),
],
},
// 关于(不在侧边栏显示)
createRoute('/about', () => import('@/views/About'), {
title: '关于',
hideInMenu: true,
}),
// 默认重定向
{ path: '/', redirect: '/dashboard' },
],
meta: { title: '登录' },
},
// ── 404 ──
// 注意: 通配路由必须放在最后,且不能被动态路由覆盖
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound'),
component: () => import('@/pages/not-found'),
meta: { title: '页面不存在' },
},
];

View File

@ -1,25 +1,20 @@
import { h, type Component, type VNode } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
/** 路由 meta 类型 */
export type AppMeta = {
/** 页面 / 菜单标题,同时用于面包屑 */
title: string;
/**
*
* - iconfont class 'icon-changguan1'
* - ant-design BarChartOutlined
*/
icon?: Component | string;
/** 是否在侧边栏菜单中隐藏(详情页等) */
icon?: string;
hideInMenu?: boolean;
/** 高亮的菜单 key路径不等于菜单 key 的场景,如详情页) */
activeMenu?: string;
};
/**
*
* PascalCase name
*
* @example
* createRoute('list', () => import('@/pages/home'), { title: '赛事列表' })
* // => { path: 'list', name: 'List', meta: {...}, component: ... }
*/
export function createRoute(
path: string,
@ -43,47 +38,3 @@ export function createRoute(
children,
};
}
/** 侧边栏菜单项ant-design-vue Menu 的 items 结构) */
export interface MenuItem {
key: string;
label: string;
icon?: VNode;
children?: MenuItem[];
}
/**
*
*
* @description
* /
* - meta.title hideInMenu
* -
* - meta.icon
*
* @param routes children
*/
export function generateMenu(routes: RouteRecordRaw[] = []): MenuItem[] {
const items: MenuItem[] = [];
for (const route of routes) {
const meta = route.meta as AppMeta | undefined;
if (!meta?.title || meta.hideInMenu) {
continue;
}
const children = generateMenu(route.children);
const item: MenuItem = { key: route.path, label: meta.title };
if (meta.icon) {
// 字符串 -> iconfont 图标(<i class="iconfont icon-xxx" />);组件 -> 直接渲染
item.icon =
typeof meta.icon === 'string' ? h('i', { class: ['iconfont', meta.icon] }) : h(meta.icon);
}
if (children.length) {
item.children = children;
}
items.push(item);
}
return items;
}

238
src/stores/menuStore.ts Normal file
View File

@ -0,0 +1,238 @@
import { reactive, computed, toRefs } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import type { MenuNode, MenuItemRaw } from '@/types';
import router, { resetRouter } from '@/router';
import { fetchMenuTree } from '@/api/menu';
import { pageModules } from '@/router/glob';
/**
* 使 import.meta.glob src/pages/
* ( glob @/router/glob.ts , TS glob )
*
* component ( 'dashboard'),
* '/src/pages/dashboard/index.tsx'
*
* src/pages/ index.tsx,
*
*/
/**
* component vite import
* 'dashboard' '/src/pages/dashboard/index.tsx'
* 'events/list' '/src/pages/events/list/index.tsx'
*/
function resolveComponent(component?: string): RouteRecordRaw['component'] | undefined {
if (!component) return undefined;
// 构建 glob key
const globKey = `/src/pages/${component}/index.tsx`;
// 直接匹配
if (pageModules[globKey]) {
return pageModules[globKey];
}
// 兜底:尝试带 @ 前缀匹配(Vite alias)
const aliasKey = `@/pages/${component}/index.tsx`;
if (pageModules[aliasKey]) {
return pageModules[aliasKey];
}
console.warn(`[路由] 未找到页面组件: ${component} (globKey: ${globKey})`);
return undefined;
}
interface MenuState {
/** 后端下发的原始菜单树 */
menuTree: MenuNode[];
/** 转换后的 antd Menu items */
menuItems: MenuItemRaw[];
/** 是否已加载(登录后只加载一次) */
loaded: boolean;
/** 首页路径(第一个可见菜单的 path) */
homePath: string;
}
const state = reactive<MenuState>({
menuTree: [],
menuItems: [],
loaded: false,
homePath: '/dashboard',
});
/**
* MenuNode antd Menu items
*/
function transformMenuNode(nodes: MenuNode[]): MenuItemRaw[] {
return nodes
.filter((node) => !node.hideInMenu && !node.externalLink)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.map((node) => {
const item: MenuItemRaw = {
key: node.path,
label: node.name,
iconName: node.icon,
};
if (node.children?.length) {
item.children = transformMenuNode(node.children);
}
return item;
});
}
/**
* MenuNode vue-router RouteRecordRaw
* component resolveComponent()
*/
function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
for (const node of nodes) {
// 外链节点不注册路由
if (node.externalLink) continue;
const componentLoader = resolveComponent(node.component);
const route: RouteRecordRaw = {
path: node.path,
name: node.name,
meta: {
title: node.name,
icon: node.icon,
hideInMenu: node.hideInMenu,
activeMenu: node.activeMenu,
externalLink: node.externalLink,
},
} as RouteRecordRaw;
if (componentLoader) {
route.component = componentLoader;
}
if (node.children?.length) {
route.children = transformMenuToRoutes(node.children);
// 没有组件的父节点自动重定向到第一个子节点
if (!componentLoader && node.children[0]) {
route.redirect = node.children[0].path;
}
}
routes.push(route);
}
return routes;
}
/**
* ()
*/
function getFirstMenuPath(nodes: MenuNode[]): string {
for (const node of nodes) {
if (!node.hideInMenu && !node.externalLink && !node.children?.length) {
return node.path;
}
if (node.children?.length) {
const childPath = getFirstMenuPath(node.children);
if (childPath) return childPath;
}
}
return '/dashboard';
}
/**
* BasicLayout ()
*
*/
function ensureLayoutRoute() {
const existing = router.hasRoute('BasicLayout');
if (!existing) {
router.addRoute({
path: '/',
name: 'BasicLayout',
component: () => import('@/layouts/BasicLayout'),
children: [],
meta: { title: '首页' },
});
}
}
/**
* (使)
*/
function registerFallbackRoutes() {
ensureLayoutRoute();
router.addRoute('BasicLayout', {
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/pages/dashboard'),
meta: { title: '工作台', icon: 'DashboardOutlined' },
});
router.addRoute('BasicLayout', {
path: 'about',
name: 'About',
component: () => import('@/pages/about'),
meta: { title: '关于', icon: 'InfoCircleOutlined' },
});
router.addRoute('BasicLayout', { path: '', redirect: '/dashboard' });
}
export function useMenuStore() {
const loadMenu = async () => {
if (state.loaded) return;
try {
const menuTree = await fetchMenuTree();
state.menuTree = 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;
} catch (err) {
console.error('加载菜单失败:', err);
// 降级:使用兜底菜单
state.menuItems = [
{ key: '/dashboard', label: '工作台', iconName: 'DashboardOutlined' },
{ key: '/about', label: '关于', iconName: 'InfoCircleOutlined' },
];
state.homePath = '/dashboard';
// 兜底路由
registerFallbackRoutes();
state.loaded = true;
}
};
const clearMenu = () => {
resetRouter();
state.menuTree = [];
state.menuItems = [];
state.loaded = false;
state.homePath = '/dashboard';
};
return {
...toRefs(state),
menuTree: computed(() => state.menuTree),
menuItems: computed(() => state.menuItems),
loaded: computed(() => state.loaded),
homePath: computed(() => state.homePath),
loadMenu,
clearMenu,
};
}

View File

@ -0,0 +1,66 @@
import { reactive, computed, toRefs } from 'vue';
import type { PermissionCode } from '@/types';
import { fetchPermissions } from '@/api/menu';
interface PermissionState {
/** 权限编码集合 */
codes: Set<PermissionCode>;
/** 是否已加载 */
loaded: boolean;
}
const state = reactive<PermissionState>({
codes: new Set(),
loaded: false,
});
export function usePermissionStore() {
/**
*
*/
const loadPermissions = async () => {
if (state.loaded) return;
try {
const codes = await fetchPermissions();
state.codes = new Set(codes);
state.loaded = true;
} catch (err) {
console.error('加载权限失败:', err);
state.codes = new Set();
state.loaded = true;
}
};
/**
* (退)
*/
const clearPermissions = () => {
state.codes = new Set();
state.loaded = false;
};
/**
*
* @param code , 'user:delete'
*/
const hasPermission = (code: PermissionCode): boolean => {
return state.codes.has(code);
};
/**
*
*/
const hasAnyPermission = (codes: PermissionCode[]): boolean => {
return codes.some((code) => state.codes.has(code));
};
return {
...toRefs(state),
loaded: computed(() => state.loaded),
loadPermissions,
clearPermissions,
hasPermission,
hasAnyPermission,
};
}

View File

@ -23,12 +23,66 @@ export interface SelectOption {
disabled?: boolean;
}
// ── 菜单 & 权限 ──
/**
* /
* , pathcomponentmeta ,
* component import.meta.glob ,
*
*/
export interface MenuNode {
/** 菜单唯一标识 */
id: number | string;
/** 菜单/路由名称(同时作为 route.name) */
name: string;
/** 路由路径(如 /dashboard、/events/list) */
path: string;
/**
* ( src/pages/ )
* : 'dashboard' import('@/pages/dashboard')
* : 'events/list' import('@/pages/events/list')
* ()
*/
component?: string;
/** 菜单图标(antd icon 名,如 DashboardOutlined) */
icon?: string;
/** 排序权重(越小越靠前) */
sort?: number;
/** 是否在菜单中隐藏(如详情页、编辑页不需要在侧边栏显示) */
hideInMenu?: boolean;
/** 外链地址(有值时点击跳转外部 URL,不注册内部路由) */
externalLink?: string;
/** 高亮的菜单路径(用于详情页高亮父级菜单) */
activeMenu?: string;
/** 子菜单/子路由 */
children?: MenuNode[];
}
/**
* ( antd Menu )
*/
export interface MenuItemRaw {
key: string;
icon?: any;
/** antd icon 名称字符串,用于动态解析图标 */
iconName?: string;
label: string;
children?: MenuItemRaw[];
}
/**
*
* ,/
* 使用方式: v-if="hasPermission('user:delete')"
*/
export type PermissionCode = string;
/** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
declare module 'vue-router' {
interface RouteMeta {
title?: string;
/** 菜单图标iconfont 的 class 名(字符串) 或 组件(如 antd 图标) */
icon?: import('vue').Component | string;
icon?: string;
/** 是否需要登录 */
requiresAuth?: boolean;
/** 是否在菜单中隐藏 */
@ -39,5 +93,7 @@ declare module 'vue-router' {
activeMenu?: string;
/** 权限标识 */
permission?: string;
/** 外链地址 */
externalLink?: string;
}
}

View File

@ -1,184 +0,0 @@
import { defineComponent, reactive, ref, watch } from 'vue';
import { Modal, Form, Input, Select, message } from 'ant-design-vue';
import type { Rule } from 'ant-design-vue/es/form';
import { saveVenue, statusMap, type VenueSaveParams } from '../model';
interface AddVenueModalProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
/**
*
*/
export default defineComponent({
name: 'AddVenueModal',
props: {
open: {
type: Boolean,
default: false,
},
onClose: {
type: Function,
required: true,
},
onSuccess: {
type: Function,
default: undefined,
},
},
setup(props: AddVenueModalProps) {
const labelCol = { span: 5 };
const wrapperCol = { span: 18 };
// 表单实例
const formRef = ref();
// 提交中状态
const submitting = ref(false);
// 表单数据初始值
const getInitialForm = (): VenueSaveParams => ({
name: '',
type: '',
contact: '',
phone: '',
address: '',
status: 'active',
});
const formState = reactive<VenueSaveParams>(getInitialForm());
// 校验规则
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入场馆名称', trigger: 'blur' }],
type: [{ required: true, message: '请输入场馆类型', trigger: 'blur' }],
contact: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: '手机号格式不正确',
trigger: 'blur',
},
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
};
// 打开时重置表单
watch(
() => props.open,
(open) => {
if (open) {
Object.assign(formState, getInitialForm());
formRef.value?.clearValidate?.();
}
},
);
/** 确定:校验 -> 提交 -> 成功回调 */
const handleOk = async () => {
try {
await formRef.value?.validate();
} catch {
// 校验不通过,直接返回
return;
}
submitting.value = true;
try {
await saveVenue({ ...formState });
message.success('新增成功');
props.onSuccess?.();
props.onClose();
} catch {
message.error('新增失败,请重试');
} finally {
submitting.value = false;
}
};
return () => (
<Modal
open={props.open}
title="新增场馆"
width={560}
centered
destroyOnClose
confirmLoading={submitting.value}
onOk={handleOk}
onCancel={() => props.onClose()}
>
<Form
ref={formRef}
model={formState}
rules={rules}
labelCol={labelCol}
wrapperCol={wrapperCol}
style={{ marginTop: '8px' }}
>
<Form.Item label="场馆名称" name="name">
<Input
value={formState.name}
onChange={(e: any) => {
formState.name = e.target.value;
}}
placeholder="请输入场馆名称"
/>
</Form.Item>
<Form.Item label="场馆类型" name="type">
<Input
value={formState.type}
onChange={(e: any) => {
formState.type = e.target.value;
}}
placeholder="请输入场馆类型"
/>
</Form.Item>
<Form.Item label="联系人" name="contact">
<Input
value={formState.contact}
onChange={(e: any) => {
formState.contact = e.target.value;
}}
placeholder="请输入联系人"
/>
</Form.Item>
<Form.Item label="联系电话" name="phone">
<Input
value={formState.phone}
onChange={(e: any) => {
formState.phone = e.target.value;
}}
placeholder="请输入联系电话"
/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
value={formState.status}
onChange={(val: any) => {
formState.status = val;
}}
placeholder="请选择状态"
>
{Object.entries(statusMap).map(([value, { text }]) => (
<Select.Option key={value} value={value}>
{text}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="地址" name="address">
<Input.TextArea
value={formState.address}
onChange={(e: any) => {
formState.address = e.target.value;
}}
placeholder="请输入地址"
rows={3}
/>
</Form.Item>
</Form>
</Modal>
);
},
});

View File

@ -1,86 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 30px);
}
.filterCard {
flex-shrink: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
padding: 20px 24px;
}
}
.filterRow {
:global(.ant-form) {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
row-gap: 12px;
column-gap: 16px;
}
:global(.ant-form-item) {
flex-shrink: 0;
margin-right: 0;
margin-bottom: 0;
}
}
.tableCard {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
}
.tableBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.tableHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.tableFooter {
flex-shrink: 0;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.pagination {
display: flex;
align-items: center;
gap: 16px;
}
.paginationText {
color: #595959;
font-size: 14px;
}

View File

@ -1,247 +0,0 @@
import { defineComponent } from 'vue';
import {
Card,
Table,
Input,
Select,
Button,
Space,
Tag,
Popconfirm,
Tooltip,
Pagination,
Form,
} from 'ant-design-vue';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
EyeOutlined,
} from '@ant-design/icons-vue';
import { useDashboardModel, type VenueItem } from './model';
import AddVenueModal from './components/AddVenueModal';
import styles from './index.module.less';
/**
* /
*/
export default defineComponent({
name: 'Dashboard',
setup() {
const {
tableData,
currentPage,
formRef,
formState,
statusMap,
addModalOpen,
openAddModal,
closeAddModal,
handleReset,
fetchVenueList,
} = useDashboardModel();
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 100,
},
{
title: '场馆名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '场馆类型',
dataIndex: 'type',
key: 'type',
width: 120,
},
{
title: '联系人',
dataIndex: 'contact',
key: 'contact',
width: 120,
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 140,
},
{
title: '地址',
dataIndex: 'address',
key: 'address',
// 开启文本溢出省略 + 悬浮 tooltip
customRender: ({ record }: { record: VenueItem }) => (
<Tooltip title={record.address}>
<div class="match-ellipsis-single">{record.address}</div>
</Tooltip>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
customRender: ({ record }: { record: VenueItem }) => {
const status = statusMap[record.status] || {
color: 'default',
text: '未知',
};
return <Tag color={status.color}>{status.text}</Tag>;
},
},
{
title: '',
key: 'avatar',
width: 80,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
customRender: () => (
<Space size="small">
<Button type="link" size="small" icon={<EyeOutlined />}>
</Button>
{/* <i class="iconfont icon-fenzutitle" style={{ fontSize: '18px', color: '#1677ff' }} /> */}
<Button type="link" size="small" icon={<EditOutlined />}>
</Button>
<Popconfirm title="确定要删除吗?" okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return () => (
<div class={styles.container}>
<Card class={styles.filterCard}>
<div class={styles.filterRow}>
<Form ref={formRef} layout="inline">
<Form.Item name="name">
<Input
value={formState.name}
onChange={(e: any) => {
formState.name = e.target.value;
}}
placeholder="请输入场馆名称"
prefix={<SearchOutlined />}
style={{ width: '200px' }}
/>
</Form.Item>
<Form.Item label="场馆类型" name="type">
<Select
value={formState.type}
onChange={(val: any) => {
formState.type = val;
}}
placeholder="请选择场馆类型"
style={{ width: '160px' }}
allowClear={false}
/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
value={formState.status}
onChange={(val: any) => {
formState.status = val;
}}
placeholder="请选择状态"
style={{ width: '140px' }}
allowClear={false}
>
{Object.entries(statusMap).map(([value, { text }]) => (
<Select.Option key={value} value={value}>
{text}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="联系人" name="contact">
<Input
value={formState.contact}
onChange={(e: any) => {
formState.contact = e.target.value;
}}
placeholder="请输入联系人"
style={{ width: '150px' }}
/>
</Form.Item>
<Form.Item label="联系电话" name="phone">
<Input
value={formState.phone}
onChange={(e: any) => {
formState.phone = e.target.value;
}}
placeholder="请输入联系电话"
style={{ width: '150px' }}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary"></Button>
<Button onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
</div>
</Card>
<Card class={styles.tableCard}>
<div class={styles.tableHeader}>
<span>{/* 场馆列表 */}</span>
<Button
type="primary"
/* icon={<i class="iconfont icon-tianjia" />} */
icon={<PlusOutlined />}
onClick={openAddModal}
>
</Button>
</div>
<div class={styles.tableBody}>
<Table
columns={columns}
dataSource={tableData.value}
pagination={false}
scroll={{ x: 1400 }}
/>
</div>
<div class={styles.tableFooter}>
<Pagination
current={currentPage.value}
onChange={(page: number) => {
currentPage.value = page;
}}
total={500}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `${total}`}
/>
</div>
</Card>
{/* 新增场馆弹框 */}
<AddVenueModal
open={addModalOpen.value}
onClose={closeAddModal}
onSuccess={fetchVenueList}
/>
</div>
);
},
});

View File

@ -1,2 +0,0 @@
export * from './useDashboardModel';
export * from './services';

View File

@ -1,119 +0,0 @@
import { get, post } from '@/utils/request';
/** 场馆列表行数据 */
export interface VenueItem {
key: string;
id: string;
name: string;
type: string;
contact: string;
status: string;
phone: string;
address: string;
createTime: string;
}
/** 场馆状态映射:状态值 -> 颜色 / 文案 */
export const statusMap: Record<string, { color: string; text: string }> = {
active: { color: 'green', text: '正常' },
inactive: { color: 'red', text: '停用' },
pending: { color: 'orange', text: '待审' },
};
/** 场馆列表查询参数 */
export interface VenueListParams {
page: number;
size: number;
name?: string;
type?: string;
status?: string;
contact?: string;
phone?: string;
}
const venueListApi = '/venue/page';
/**
*
* @description useDashboardModel mock
*/
export const getVenueList = (params: VenueListParams) => {
return get(venueListApi, params);
};
/** 新增场馆参数 */
export interface VenueSaveParams {
name: string;
type: string;
contact: string;
phone: string;
address: string;
status: string;
}
const venueSaveApi = '/venue/save';
/** 新增场馆接口 */
export const saveVenue = (params: VenueSaveParams) => {
return post(venueSaveApi, params);
};
/** 临时 mock 数据(接口对接前占位展示) */
export const mockVenueList: VenueItem[] = [
{
key: '1',
id: 'V001',
name: '健身中心',
type: '健身馆',
contact: '张三',
status: 'active',
phone: '13999999999',
address: '北京市朝阳区xxx路xxx号',
createTime: '2024-01-15 10:30:00',
},
{
key: '2',
id: 'V002',
name: '篮球公园',
type: '篮球馆',
contact: '李四',
status: 'active',
phone: '13999999999',
address: '上海市浦东新区xxx路xxx号',
createTime: '2024-01-16 14:20:00',
},
{
key: '3',
id: 'V003',
name: '游泳馆',
type: '游泳馆',
contact: '王五',
status: 'inactive',
phone: '13999999999',
address: '广州市天河区xxx路xxx号',
createTime: '2024-01-17 09:15:00',
},
{
key: '4',
id: 'V004',
name: '羽毛球馆',
type: '羽毛球馆',
contact: '马六',
status: 'active',
phone: '13999999999',
address: '深圳市南山区xxx路xxx号',
createTime: '2024-01-18 16:45:00',
},
{
key: '5',
id: 'V005',
name: '网球中心',
type: '网球馆',
contact: '冯七',
status: 'pending',
phone: '13999999999',
address:
'杭州市西湖区xxx路xxx号杭州市西湖区xxx路xxx号杭州市西湖区xxx路xxx号杭州市西湖区xxx路xxx号11',
createTime: '2024-01-19 11:00:00',
},
];

View File

@ -1,83 +0,0 @@
import { reactive, ref } from 'vue';
import { getVenueList, mockVenueList, statusMap, type VenueItem } from './services';
/** 筛选表单字段 */
export interface VenueFilterForm {
name: string;
type?: string;
status?: string;
contact: string;
phone: string;
}
/**
* Dashboard()
*
* @description
* index.tsx
*/
export function useDashboardModel() {
// 表格数据(暂用 mock接口就绪后由 fetchVenueList 填充)
const tableData = ref<VenueItem[]>(mockVenueList);
// 当前页码
const currentPage = ref(1);
// 筛选表单实例
const formRef = ref();
// 筛选表单数据
const formState = reactive<VenueFilterForm>({
name: '',
type: undefined,
status: undefined,
contact: '',
phone: '',
});
// 新增场馆弹框显隐
const addModalOpen = ref(false);
/** 打开新增弹框 */
const openAddModal = () => {
addModalOpen.value = true;
};
/** 关闭新增弹框 */
const closeAddModal = () => {
addModalOpen.value = false;
};
/** 重置筛选表单 */
const handleReset = () => {
formRef.value?.resetFields();
Object.assign(formState, {
name: '',
type: undefined,
status: undefined,
contact: '',
phone: '',
});
};
/**
*
* @description mock
*/
const fetchVenueList = async () => {
const res: any = await getVenueList({
page: currentPage.value,
size: 10,
...formState,
});
tableData.value = res?.data?.list ?? [];
};
return {
tableData,
currentPage,
formRef,
formState,
statusMap,
addModalOpen,
openAddModal,
closeAddModal,
handleReset,
fetchVenueList,
};
}

View File

@ -1,67 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 30px);
}
.filterCard {
flex-shrink: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
padding: 20px 24px;
}
}
.filterRow {
:global(.ant-form) {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
row-gap: 12px;
column-gap: 16px;
}
:global(.ant-form-item) {
flex-shrink: 0;
margin-right: 0;
margin-bottom: 0;
}
}
.tableCard {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
}
.tableBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.tableFooter {
flex-shrink: 0;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}

View File

@ -1,139 +0,0 @@
import { defineComponent } from 'vue';
import {
Card,
Table,
Input,
Select,
Button,
Space,
Tag,
Popconfirm,
Pagination,
Form,
} from 'ant-design-vue';
import { SearchOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons-vue';
import { useVenueListModel, type VenueListItem } from './model';
import styles from './index.module.less';
/**
* /
*/
export default defineComponent({
name: 'VenueList',
setup() {
const { tableData, currentPage, formRef, formState, statusMap, handleReset } =
useVenueListModel();
const columns = [
{ title: '序号', dataIndex: 'id', key: 'id', width: 100 },
{ title: '场馆名称', dataIndex: 'name', key: 'name', width: 160 },
{ title: '场馆类型', dataIndex: 'type', key: 'type', width: 140 },
{ title: '联系人', dataIndex: 'contact', key: 'contact', width: 120 },
{ title: '联系电话', dataIndex: 'phone', key: 'phone', width: 160 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
customRender: ({ record }: { record: VenueListItem }) => {
const status = statusMap[record.status] || {
color: 'default',
text: '未知',
};
return <Tag color={status.color}>{status.text}</Tag>;
},
},
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
customRender: () => (
<Space size="small">
<Button type="link" size="small" icon={<EyeOutlined />}>
</Button>
<Button type="link" size="small" icon={<EditOutlined />}>
</Button>
<Popconfirm title="确定要删除吗?" okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return () => (
<div class={styles.container}>
<Card class={styles.filterCard}>
<div class={styles.filterRow}>
<Form ref={formRef} layout="inline">
<Form.Item name="name">
<Input
value={formState.name}
onChange={(e: any) => {
formState.name = e.target.value;
}}
placeholder="请输入场馆名称"
prefix={<SearchOutlined />}
style={{ width: '200px' }}
/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
value={formState.status}
onChange={(val: any) => {
formState.status = val;
}}
placeholder="请选择状态"
style={{ width: '140px' }}
allowClear={false}
>
{Object.entries(statusMap).map(([value, { text }]) => (
<Select.Option key={value} value={value}>
{text}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary"></Button>
<Button onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
</div>
</Card>
<Card class={styles.tableCard}>
<div class={styles.tableBody}>
<Table
columns={columns}
dataSource={tableData.value}
pagination={false}
scroll={{ x: 1200 }}
/>
</div>
<div class={styles.tableFooter}>
<Pagination
current={currentPage.value}
onChange={(page: number) => {
currentPage.value = page;
}}
total={500}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `${total}`}
/>
</div>
</Card>
</div>
);
},
});

View File

@ -1,2 +0,0 @@
export * from './useVenueListModel';
export * from './services';

View File

@ -1,72 +0,0 @@
import { get } from '@/utils/request';
/** 场馆列表行数据 */
export interface VenueListItem {
key: string;
id: string;
name: string;
type: string;
contact: string;
status: string;
phone: string;
createTime: string;
}
/** 场馆状态映射:状态值 -> 颜色 / 文案 */
export const statusMap: Record<string, { color: string; text: string }> = {
active: { color: 'green', text: '正常' },
inactive: { color: 'red', text: '停用' },
pending: { color: 'orange', text: '待审' },
};
/** 场馆列表查询参数 */
export interface VenueListParams {
page: number;
size: number;
name?: string;
status?: string;
}
const venueListApi = '/venue/list';
/**
* -
* @description useVenueListModel mock
*/
export const getVenueList = (params: VenueListParams) => {
return get(venueListApi, params);
};
/** 临时 mock 数据(接口对接前占位展示) */
export const mockVenueList: VenueListItem[] = [
{
key: '1',
id: 'V001',
name: '健身中心',
type: '健身馆',
contact: '张三',
status: 'active',
phone: '13999999999',
createTime: '2024-01-15 10:30:00',
},
{
key: '2',
id: 'V002',
name: '篮球公园',
type: '篮球馆',
contact: '李四',
status: 'active',
phone: '13999999999',
createTime: '2024-01-16 14:20:00',
},
{
key: '3',
id: 'V003',
name: '游泳馆',
type: '游泳馆',
contact: '王五',
status: 'inactive',
phone: '13999999999',
createTime: '2024-01-17 09:15:00',
},
];

View File

@ -1,54 +0,0 @@
import { reactive, ref } from 'vue';
import { getVenueList, mockVenueList, statusMap, type VenueListItem } from './services';
/** 筛选表单字段 */
export interface VenueListFilterForm {
name: string;
status?: string;
}
/**
* ()
*/
export function useVenueListModel() {
// 表格数据(暂用 mock接口就绪后由 fetchList 填充)
const tableData = ref<VenueListItem[]>(mockVenueList);
// 当前页码
const currentPage = ref(1);
// 筛选表单实例
const formRef = ref();
// 筛选表单数据
const formState = reactive<VenueListFilterForm>({
name: '',
status: undefined,
});
/** 重置筛选表单 */
const handleReset = () => {
formRef.value?.resetFields();
Object.assign(formState, { name: '', status: undefined });
};
/**
*
* @description mock
*/
const fetchList = async () => {
const res: any = await getVenueList({
page: currentPage.value,
size: 10,
...formState,
});
tableData.value = res?.data?.list ?? [];
};
return {
tableData,
currentPage,
formRef,
formState,
statusMap,
handleReset,
fetchList,
};
}

View File

@ -1,67 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 30px);
}
.filterCard {
flex-shrink: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
padding: 20px 24px;
}
}
.filterRow {
:global(.ant-form) {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
row-gap: 12px;
column-gap: 16px;
}
:global(.ant-form-item) {
flex-shrink: 0;
margin-right: 0;
margin-bottom: 0;
}
}
.tableCard {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
:global(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
}
.tableBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.tableFooter {
flex-shrink: 0;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}

View File

@ -1,137 +0,0 @@
import { defineComponent } from 'vue';
import {
Card,
Table,
Input,
Select,
Button,
Space,
Tag,
Popconfirm,
Pagination,
Form,
} from 'ant-design-vue';
import { SearchOutlined, EyeOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons-vue';
import { useVenueRecordsModel, type VenueRecordItem } from './model';
import styles from './index.module.less';
/**
* /
*/
export default defineComponent({
name: 'VenueRecords',
setup() {
const { tableData, currentPage, formRef, formState, statusMap, handleReset } =
useVenueRecordsModel();
const columns = [
{ title: '序号', dataIndex: 'id', key: 'id', width: 100 },
{ title: '场馆名称', dataIndex: 'venueName', key: 'venueName', width: 180 },
{ title: '来源', dataIndex: 'source', key: 'source', width: 140 },
{
title: '收录状态',
dataIndex: 'status',
key: 'status',
width: 120,
customRender: ({ record }: { record: VenueRecordItem }) => {
const status = statusMap[record.status] || {
color: 'default',
text: '未知',
};
return <Tag color={status.color}>{status.text}</Tag>;
},
},
{ title: '收录时间', dataIndex: 'recordTime', key: 'recordTime', width: 180 },
{
title: '操作',
key: 'action',
width: 240,
fixed: 'right' as const,
customRender: () => (
<Space size="small">
<Button type="link" size="small" icon={<EyeOutlined />}>
</Button>
<Button type="link" size="small" icon={<CheckOutlined />}>
</Button>
<Popconfirm title="确定要驳回吗?" okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<CloseOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return () => (
<div class={styles.container}>
<Card class={styles.filterCard}>
<div class={styles.filterRow}>
<Form ref={formRef} layout="inline">
<Form.Item name="venueName">
<Input
value={formState.venueName}
onChange={(e: any) => {
formState.venueName = e.target.value;
}}
placeholder="请输入场馆名称"
prefix={<SearchOutlined />}
style={{ width: '200px' }}
/>
</Form.Item>
<Form.Item label="收录状态" name="status">
<Select
value={formState.status}
onChange={(val: any) => {
formState.status = val;
}}
placeholder="请选择状态"
style={{ width: '140px' }}
allowClear={false}
>
{Object.entries(statusMap).map(([value, { text }]) => (
<Select.Option key={value} value={value}>
{text}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary"></Button>
<Button onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
</div>
</Card>
<Card class={styles.tableCard}>
<div class={styles.tableBody}>
<Table
columns={columns}
dataSource={tableData.value}
pagination={false}
scroll={{ x: 1000 }}
/>
</div>
<div class={styles.tableFooter}>
<Pagination
current={currentPage.value}
onChange={(page: number) => {
currentPage.value = page;
}}
total={500}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `${total}`}
/>
</div>
</Card>
</div>
);
},
});

View File

@ -1,2 +0,0 @@
export * from './useVenueRecordsModel';
export * from './services';

View File

@ -1,64 +0,0 @@
import { get } from '@/utils/request';
/** 收录记录行数据 */
export interface VenueRecordItem {
key: string;
id: string;
venueName: string;
source: string;
status: string;
recordTime: string;
}
/** 收录状态映射:状态值 -> 颜色 / 文案 */
export const statusMap: Record<string, { color: string; text: string }> = {
recorded: { color: 'green', text: '已收录' },
reviewing: { color: 'orange', text: '审核中' },
rejected: { color: 'red', text: '已驳回' },
};
/** 收录记录查询参数 */
export interface VenueRecordParams {
page: number;
size: number;
venueName?: string;
status?: string;
}
const venueRecordApi = '/venue/records';
/**
*
* @description useVenueRecordsModel mock
*/
export const getVenueRecords = (params: VenueRecordParams) => {
return get(venueRecordApi, params);
};
/** 临时 mock 数据(接口对接前占位展示) */
export const mockVenueRecords: VenueRecordItem[] = [
{
key: '1',
id: 'R001',
venueName: '健身中心',
source: '用户提交',
status: 'recorded',
recordTime: '2024-01-15 10:30:00',
},
{
key: '2',
id: 'R002',
venueName: '篮球公园',
source: '平台采集',
status: 'reviewing',
recordTime: '2024-01-16 14:20:00',
},
{
key: '3',
id: 'R003',
venueName: '游泳馆',
source: '用户提交',
status: 'rejected',
recordTime: '2024-01-17 09:15:00',
},
];

View File

@ -1,54 +0,0 @@
import { reactive, ref } from 'vue';
import { getVenueRecords, mockVenueRecords, statusMap, type VenueRecordItem } from './services';
/** 筛选表单字段 */
export interface VenueRecordFilterForm {
venueName: string;
status?: string;
}
/**
*
*/
export function useVenueRecordsModel() {
// 表格数据(暂用 mock接口就绪后由 fetchList 填充)
const tableData = ref<VenueRecordItem[]>(mockVenueRecords);
// 当前页码
const currentPage = ref(1);
// 筛选表单实例
const formRef = ref();
// 筛选表单数据
const formState = reactive<VenueRecordFilterForm>({
venueName: '',
status: undefined,
});
/** 重置筛选表单 */
const handleReset = () => {
formRef.value?.resetFields();
Object.assign(formState, { venueName: '', status: undefined });
};
/**
*
* @description mock
*/
const fetchList = async () => {
const res: any = await getVenueRecords({
page: currentPage.value,
size: 10,
...formState,
});
tableData.value = res?.data?.list ?? [];
};
return {
tableData,
currentPage,
formRef,
formState,
statusMap,
handleReset,
fetchList,
};
}

View File

@ -1,131 +0,0 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const readline = require('readline');
function loadEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, 'utf8');
const env = {};
content.split('\n').forEach((line) => {
const match = line.match(/^([^=:#]+?)=(.*)$/);
if (match) {
env[match[1].trim()] = match[2].trim().replace(/^['"](.*)['"]$/, '$1');
}
});
return env;
}
function getComponentLibs(env) {
const libs = [];
libs.push({
name: 'cpms_web_component (默认)',
path: path.resolve(__dirname, '../cpms_web_component'),
useKey: null,
usePath: null,
});
for (const key in env) {
if (key.startsWith('COMPONENT_LIB_') && key !== 'COMPONENT_LIB_PATH') {
libs.push({
name: key.replace('COMPONENT_LIB_', ''),
path: path.resolve(__dirname, env[key]),
useKey: key.replace('COMPONENT_LIB_', ''),
usePath: null,
});
}
}
if (env.COMPONENT_LIB_PATH) {
libs.push({
name: '自定义路径',
path: path.resolve(__dirname, env.COMPONENT_LIB_PATH),
useKey: null,
usePath: env.COMPONENT_LIB_PATH,
});
}
return libs;
}
async function selectLib(libs) {
if (libs.length === 1) return libs[0];
console.log('📦 请选择要使用的组件库:');
libs.forEach((lib, i) => {
const exists = fs.existsSync(lib.path) ? '✅' : '❌';
console.log(` ${i + 1}. ${lib.name} ${exists}`);
console.log(` ${lib.path}`);
});
console.log(' 0. 使用 npm 版本');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`\n请输入选项 (0-${libs.length},回车默认): `, (answer) => {
rl.close();
if (answer === '0') resolve(null);
else if (answer && /^\d+$/.test(answer)) {
const idx = parseInt(answer) - 1;
resolve(idx >= 0 && idx < libs.length ? libs[idx] : libs[0]);
} else {
resolve(libs[0]);
}
});
});
}
async function main() {
const localEnv = loadEnv(path.resolve(__dirname, '.env.local'));
const libs = getComponentLibs(localEnv);
console.log('🚀 启动本地调试模式...\n');
const selected = await selectLib(libs);
const mergedEnv = { ...process.env, ...localEnv, LOCAL_DEBUG: 'true' };
if (!selected) {
console.log('\n📦 使用 npm 版本');
delete mergedEnv.COMPONENT_LIB;
delete mergedEnv.COMPONENT_LIB_PATH;
} else {
const exists = fs.existsSync(selected.path);
if (!exists) {
console.warn(`\n⚠️ 组件库不存在: ${selected.name}`);
console.warn(` ${selected.path}`);
console.warn(' 将使用 npm 版本');
delete mergedEnv.COMPONENT_LIB;
delete mergedEnv.COMPONENT_LIB_PATH;
} else {
console.log(`\n📦 ${selected.name}`);
console.log(`📍 ${selected.path}`);
if (selected.useKey) {
mergedEnv.COMPONENT_LIB = selected.useKey;
delete mergedEnv.COMPONENT_LIB_PATH;
} else if (selected.usePath) {
mergedEnv.COMPONENT_LIB_PATH = selected.usePath;
delete mergedEnv.COMPONENT_LIB;
} else {
delete mergedEnv.COMPONENT_LIB;
delete mergedEnv.COMPONENT_LIB_PATH;
}
}
}
console.log('\n⏳ 启动 Vite...\n');
try {
execSync('npx vite --port 3000 --open --strictPort false', {
stdio: 'inherit',
cwd: process.cwd(),
env: mergedEnv,
});
} catch (error) {
console.error('❌ 启动失败:', error.message);
process.exit(1);
}
}
main();

View File

@ -1,15 +1,28 @@
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import removeConsole from 'vite-plugin-remove-console';
import strip from '@rollup/plugin-strip';
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const isProd = mode === 'production';
return {
base: env.VITE_BASE_URL || '/',
plugins: [vue(), vueJsx()],
plugins: [
vue(),
vueJsx(),
// 生产构建:移除 console.log / console.warn / console.error
isProd && removeConsole(),
// 生产构建:移除代码中的注释
isProd &&
strip({
include: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
}),
].filter(Boolean),
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
@ -27,7 +40,8 @@ export default defineConfig(({ mode }) => {
},
server: {
host: '0.0.0.0',
port: Number(env.VITE_PORT) || 5173,
port: 3000,
strictPort: false,
open: true,
proxy: {
'/api': {
@ -38,23 +52,28 @@ export default defineConfig(({ mode }) => {
},
},
build: {
target: 'es2020',
outDir: 'dist',
sourcemap: mode !== 'production',
// 生产构建移除 console 和注释(esbuild minify 阶段)
minify: isProd ? 'esbuild' : false,
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router'],
antd: ['ant-design-vue'],
// 更细粒度的分包,确保按需加载
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('ant-design-vue') || id.includes('@ant-design')) {
return 'antd';
}
if (id.includes('vue') || id.includes('@vue') || id.includes('vue-router')) {
return 'vue-vendor';
}
// 其他第三方依赖单独打包,未使用的不会进入
return 'vendor';
}
},
},
},
},
optimizeDeps: {
esbuildOptions: {
target: 'es2020',
},
},
};
});