Compare commits
2 Commits
feature/cs
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
5523695518 | |
|
|
3b5a908ee4 |
|
|
@ -4,4 +4,3 @@ public
|
|||
*.config.js
|
||||
*.config.ts
|
||||
start-debug.js
|
||||
src/assets/iconfont
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit "$1"
|
||||
npx --no -- commitlint --edit $1
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: {
|
||||
chrome: '80',
|
||||
firefox: '75',
|
||||
safari: '14',
|
||||
edge: '80',
|
||||
},
|
||||
useBuiltIns: 'usage',
|
||||
corejs: 3,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -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": {
|
||||
|
|
|
|||
13
src/App.tsx
13
src/App.tsx
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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.
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default defineComponent({
|
|||
setup() {
|
||||
const router = useRouter();
|
||||
|
||||
const goHome = () => router.push('/');
|
||||
const goHome = () => router.push('/login');
|
||||
|
||||
return () => (
|
||||
<Result
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: '页面不存在' },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -23,12 +23,66 @@ export interface SelectOption {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ── 菜单 & 权限 ──
|
||||
|
||||
/**
|
||||
* 后端下发的路由/菜单节点
|
||||
* 后端接口返回完整的路由配置树,包含 path、component、meta 等,
|
||||
* 前端根据 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './useDashboardModel';
|
||||
export * from './services';
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './useVenueListModel';
|
||||
export * from './services';
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './useVenueRecordsModel';
|
||||
export * from './services';
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
131
start-debug.js
131
start-debug.js
|
|
@ -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();
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue