Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b85dd9c1 | |||
| 5c7b605436 |
+4
-6
@@ -1,6 +1,4 @@
|
||||
# 开发环境配置
|
||||
# 基础API地址
|
||||
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
|
||||
|
||||
# 是否开启本地调试
|
||||
LOCAL_DEBUG=true
|
||||
# 开发环境
|
||||
VITE_PORT = 5173
|
||||
VITE_API_BASE_URL = http://localhost:3000
|
||||
VITE_API_PREFIX = /api
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
# 生产环境
|
||||
VITE_API_BASE_URL = https://api.cpms.example.com/admin
|
||||
VITE_API_BASE_URL = https://api.cpms.example.com
|
||||
VITE_API_PREFIX = /api
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# 测试环境配置
|
||||
# 基础API地址
|
||||
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
|
||||
|
||||
# 是否开启本地调试
|
||||
LOCAL_DEBUG=false
|
||||
# 测试环境
|
||||
VITE_API_BASE_URL = https://test-api.cpms.example.com
|
||||
VITE_API_PREFIX = /api
|
||||
|
||||
@@ -4,3 +4,4 @@ public
|
||||
*.config.js
|
||||
*.config.ts
|
||||
start-debug.js
|
||||
src/assets/iconfont
|
||||
|
||||
+1
-5
@@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
/logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -40,7 +40,3 @@ coverage
|
||||
.cache
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Easy LESS 误生成的编译产物(本项目为 Vue PC 端,样式源文件为 .less,由 Vite 编译)
|
||||
*.wxss
|
||||
*.module.css
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit "$1"
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
Vendored
+1
-1
@@ -12,7 +12,7 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[less]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.css-language-features"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.vue": "vue"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: {
|
||||
chrome: '80',
|
||||
firefox: '75',
|
||||
safari: '14',
|
||||
edge: '80',
|
||||
},
|
||||
useBuiltIns: 'usage',
|
||||
corejs: 3,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
+12
-15
@@ -1,17 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<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="六个羽友赛事运营平台" />
|
||||
<title>六羽赛事运营平台</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+108
-150
@@ -9,41 +9,34 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/big.js": "^7.0.0",
|
||||
"@yp-component/root": "^0.0.48",
|
||||
"@yp-component/root": "^0.0.46",
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"big.js": "^7.0.1",
|
||||
"china-area-data": "^5.0.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^5.6.0",
|
||||
"express": "^5.2.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"qs": "^6.15.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-icons-plus": "^0.1.8",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.33.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",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -948,65 +941,6 @@
|
||||
"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": [
|
||||
@@ -1052,10 +986,11 @@
|
||||
"url": "https://ko-fi.com/dangreen"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/big.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/big.js/-/big.js-7.0.0.tgz",
|
||||
"integrity": "sha512-WfAGp7IbJvyB8EmWK4tJD24rJRAL6uVbw3LV/hJntFNam+os9KWKj0PzXo8rRRpjupYK8U0M8FoBB8dBhWF2dg==",
|
||||
"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": {
|
||||
@@ -1088,6 +1023,8 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1122,6 +1059,8 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1502,9 +1441,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@yp-component/root": {
|
||||
"version": "0.0.48",
|
||||
"resolved": "http://192.168.2.127:4873/@yp-component/root/-/root-0.0.48.tgz",
|
||||
"integrity": "sha512-TOp8adAob3pMSfecQjEC4uVNCGqNWcShDjSQysd4pYY34i5ldFLRGSiJdpLsFG1xKkcSudiqEc/Si/ZfYoDSqA==",
|
||||
"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"
|
||||
@@ -1685,19 +1624,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/big.js": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/big.js/-/big.js-7.0.1.tgz",
|
||||
"integrity": "sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bigjs"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"license": "MIT",
|
||||
@@ -1861,12 +1787,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/china-area-data": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "http://192.168.2.127:4873/china-area-data/-/china-area-data-5.0.1.tgz",
|
||||
"integrity": "sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"dev": true,
|
||||
@@ -2138,7 +2058,7 @@
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "http://192.168.2.127:4873/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"resolved": "http://npm.nps.yunvip123.cn/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2268,16 +2188,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "http://192.168.2.127:4873/echarts/-/echarts-5.6.0.tgz",
|
||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
@@ -2817,6 +2727,9 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -2911,6 +2824,8 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -2930,6 +2845,31 @@
|
||||
"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,
|
||||
@@ -3090,8 +3030,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -5654,12 +5592,6 @@
|
||||
"typescript": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "http://192.168.2.127:4873/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"dev": true,
|
||||
@@ -5847,13 +5779,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -5881,35 +5806,77 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "9.4.3",
|
||||
"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==",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mysticatea"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=6.0.0"
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-icons-plus": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "http://192.168.2.127:4873/vue-icons-plus/-/vue-icons-plus-0.1.8.tgz",
|
||||
"integrity": "sha512-Xc4hDsD/oP9waSUf44nSaFBhUPo+QkpKclx0S7//5BRACpXymctbit02epek0VRW6nb81pR486XmxPP/ofm2yQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": ">=2.7.0"
|
||||
"node_modules/vue-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"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
@@ -6136,15 +6103,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "http://192.168.2.127:4873/zrender/-/zrender-5.6.1.tgz",
|
||||
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-14
@@ -5,54 +5,49 @@
|
||||
"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"
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/big.js": "^7.0.0",
|
||||
"@yp-component/root": "^0.0.48",
|
||||
"@yp-component/root": "^0.0.46",
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"big.js": "^7.0.1",
|
||||
"china-area-data": "^5.0.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^5.6.0",
|
||||
"express": "^5.2.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"qs": "^6.15.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-icons-plus": "^0.1.8",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.33.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",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
Generated
-4493
File diff suppressed because it is too large
Load Diff
+12
-6
@@ -1,10 +1,7 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { ConfigProvider } from 'ant-design-vue';
|
||||
import zhCN from 'ant-design-vue/locale/zh_CN';
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
import { RouterView } from 'vue-router';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 根组件
|
||||
@@ -15,8 +12,17 @@ export default defineComponent({
|
||||
setup() {
|
||||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 8,
|
||||
colorPrimary: '#1890ff',
|
||||
colorSuccess: '#52c41a',
|
||||
colorWarning: '#faad14',
|
||||
colorError: '#ff4d4f',
|
||||
colorInfo: '#1890ff',
|
||||
colorBgBase: '#ffffff',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBgLayout: '#f0f2f5',
|
||||
colorTextBase: '#262626',
|
||||
borderRadius: 6,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { AuthData } from './menu';
|
||||
|
||||
/** Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试 */
|
||||
let authDataPromise: Promise<AuthData> | null = null;
|
||||
|
||||
export function getAuthDataPromise() {
|
||||
return authDataPromise;
|
||||
}
|
||||
|
||||
export function setAuthDataPromise(promise: Promise<AuthData> | null) {
|
||||
authDataPromise = promise;
|
||||
}
|
||||
|
||||
/** 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据) */
|
||||
export function clearAuthDataCache() {
|
||||
authDataPromise = null;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Banner 管理 API
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
BannerListQueryParams,
|
||||
BannerSaveParams,
|
||||
BannerDelParams,
|
||||
BannerActiveParams,
|
||||
TournamentAdminBannerListVO,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
TournamentAdminBannerListVO,
|
||||
BannerListQueryParams,
|
||||
BannerSaveParams,
|
||||
BannerDelParams,
|
||||
BannerActiveParams,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /manager/banner/list — Banner 列表 */
|
||||
export async function getBannerList(
|
||||
params: BannerListQueryParams,
|
||||
): Promise<ApiResult<TournamentAdminBannerListVO[]>> {
|
||||
return get('/manager/banner/list', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** POST /manager/banner/del — 删除 Banner */
|
||||
export function delBanner(id: string): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/banner/del', { id });
|
||||
}
|
||||
|
||||
/** POST /manager/banner/save — 新增 Banner */
|
||||
export function saveBanner(params: BannerSaveParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/banner/save', params);
|
||||
}
|
||||
|
||||
/** POST /manager/banner/update �� 编辑 Banner */
|
||||
export function updateBanner(params: BannerSaveParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/banner/update', params);
|
||||
}
|
||||
|
||||
/** POST /manager/banner/active — 启用/禁用 Banner */
|
||||
export function toggleBannerActive(
|
||||
params: BannerActiveParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/banner/active', params);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Banner 管理 — 类型定义
|
||||
*/
|
||||
|
||||
/** Banner 列表项 */
|
||||
export interface TournamentAdminBannerListVO {
|
||||
id: string;
|
||||
img: string;
|
||||
title: string;
|
||||
type: string;
|
||||
params: string;
|
||||
area: string;
|
||||
showTimeBegin: string;
|
||||
showTimeEnd: string;
|
||||
status: string;
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** Banner 列表查询参数 */
|
||||
export interface BannerListQueryParams {
|
||||
title?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Banner 新增/编辑请求 */
|
||||
export interface BannerSaveParams {
|
||||
id?: string;
|
||||
img: string;
|
||||
title: string;
|
||||
type: string;
|
||||
params: string;
|
||||
area: string;
|
||||
showTimeBegin: string;
|
||||
showTimeEnd: string;
|
||||
sort: string;
|
||||
}
|
||||
|
||||
/** Banner 删除请求 */
|
||||
export interface BannerDelParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Banner 启用/禁用请求 */
|
||||
export interface BannerActiveParams {
|
||||
id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 赛事管理 API
|
||||
* 注意:/admin 前缀已通过 VITE_API_BASE_URL 统一注入(见 .env.*),此文件中只写业务路径。
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
EventListQueryParams,
|
||||
SignupListQueryParams,
|
||||
ToggleOnlineParams,
|
||||
ApiResult,
|
||||
PageData,
|
||||
TournamentAdminVO,
|
||||
TournamentAdminInfoVO,
|
||||
TournamentAdminInfoSignupVO,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
TournamentAdminVO,
|
||||
TournamentAdminInfoVO,
|
||||
TournamentAdminInfoSignupVO,
|
||||
InnerCategoryVO,
|
||||
EventListQueryParams,
|
||||
SignupListQueryParams,
|
||||
ToggleOnlineParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 赛事列表 - 列表分页
|
||||
* GET /admin/manager/tm/page → /manager/tm/page
|
||||
*/
|
||||
export async function getEventList(
|
||||
params: EventListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminVO>>> {
|
||||
return get('/manager/tm/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事列表 - 赛事详情
|
||||
* GET /admin/manager/tm/info → /manager/tm/info
|
||||
*/
|
||||
export async function getEventDetail(id: string): Promise<ApiResult<TournamentAdminInfoVO>> {
|
||||
return get('/manager/tm/info', { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事列表 - 赛事详情 - 人员列表
|
||||
* GET /admin/manager/tm/info/signupPage → /manager/tm/info/signupPage
|
||||
*/
|
||||
export async function getSignupList(
|
||||
params: SignupListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminInfoSignupVO>>> {
|
||||
return get('/manager/tm/info/signupPage', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事上架 / 下架
|
||||
* POST /admin/manager/tm/online → /manager/tm/online
|
||||
* @param params.id 赛事ID
|
||||
* @param params.online 0=下架,1=上架
|
||||
*/
|
||||
export function toggleEventOnline(
|
||||
params: ToggleOnlineParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/tm/online', params);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* 赛事管理 — 类型定义
|
||||
* 对应 OpenAPI: /admin/manager/tm/page
|
||||
*/
|
||||
|
||||
/** 赛事列表项 */
|
||||
export interface TournamentAdminVO {
|
||||
/** 赛事ID */
|
||||
id: string;
|
||||
/** 赛事名称 */
|
||||
name: string;
|
||||
/** 赛事开始时间 */
|
||||
startTimeBegin: string;
|
||||
/** 赛事结束时间 */
|
||||
startTimeEnd: string;
|
||||
/** 赛事省市区,场地区划名称 */
|
||||
venueArea: string;
|
||||
/** 赛事地点名称 */
|
||||
venue: string;
|
||||
/** 是否公开;0=非公开,1=公开 */
|
||||
isPublic: string;
|
||||
/** 是否要求提供证件图片;0=无需,1=需要 */
|
||||
isRequireIdCardImg: string;
|
||||
/** 是否要求提供证件号码;0=无需,1=需要 */
|
||||
isRequireIdCardNo: string;
|
||||
/** 创建人昵称 */
|
||||
createNickname: string;
|
||||
/** 创建人手机号 */
|
||||
createPhone: string;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/**
|
||||
* 状态
|
||||
* 优先级:del_flag==1 → 3(已删除);online==0 → 4(已下架);否则取 status
|
||||
* 0=报名中,1=进行中,2=已结束,3=已删除,4=已下架
|
||||
*/
|
||||
status: string;
|
||||
/** 报名人数 */
|
||||
signupCount: string;
|
||||
/** 退赛人数 */
|
||||
dropoutCount: string;
|
||||
/** 赛事规程 */
|
||||
rulesDescr: string;
|
||||
/** 赛事规程图片 */
|
||||
rulesDescrImg: string;
|
||||
/** 赛事规程图片数组 */
|
||||
rulesDescrImgList: string[];
|
||||
}
|
||||
|
||||
/** 赛事列表查询参数 */
|
||||
export interface EventListQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 创建时间开始 yyyy-MM-dd */
|
||||
createDate?: string;
|
||||
/** 创建时间结束 yyyy-MM-dd */
|
||||
createDateEnd?: string;
|
||||
/** 赛事时间开始 yyyy-MM-dd */
|
||||
startDate?: string;
|
||||
/** 赛事时间结束 yyyy-MM-dd */
|
||||
startDateEnd?: string;
|
||||
/** 赛事名称 */
|
||||
name?: string;
|
||||
/** 创建人昵称或手机号 */
|
||||
createName?: string;
|
||||
/**
|
||||
* 赛事状态
|
||||
* 不传=全部、0=报名中,1=进行中,2=已结束,3=已删除,4=已下架
|
||||
*/
|
||||
status?: string;
|
||||
/** 市级别城市名称 */
|
||||
areaName?: string;
|
||||
/** 是否公开;0=非公开,1=公开 */
|
||||
isPublic?: string;
|
||||
/** 是否要求提供证件图片;0=无需,1=需要 */
|
||||
idCardImg?: string;
|
||||
/** 是否要求提供证件号码;0=无需,1=需要 */
|
||||
idCardNo?: string;
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 统一响应包裹 */
|
||||
export interface ApiResult<T> {
|
||||
/** 编码:200表示成功,其他值表示失败 */
|
||||
code: string;
|
||||
/** 消息内容 */
|
||||
msg: string;
|
||||
/** 响应数据 */
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 赛事详情(对应 /manager/tm/info)
|
||||
// ============================================================
|
||||
|
||||
/** 组别信息 */
|
||||
export interface InnerCategoryVO {
|
||||
/** 组别ID */
|
||||
id: string;
|
||||
/** 组别名称 */
|
||||
name: string;
|
||||
/** 组别类型;1=单打,2=双打 */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** 赛事详情 */
|
||||
export interface TournamentAdminInfoVO {
|
||||
/** 赛事名称 */
|
||||
name: string;
|
||||
/** 赛事开始时间 */
|
||||
startTimeBegin: string;
|
||||
/** 赛事结束时间 */
|
||||
startTimeEnd: string;
|
||||
/** 赛事说明 */
|
||||
descr: string;
|
||||
/** 报名开始时间 */
|
||||
signupTimeBegin: string;
|
||||
/** 报名结束时间 */
|
||||
signupTimeEnd: string;
|
||||
/** 赛事省市区 */
|
||||
venueArea: string;
|
||||
/** 赛事地点名称 */
|
||||
venue: string;
|
||||
/** 场地号 */
|
||||
venueNo: string;
|
||||
/** 是否公开;0=非公开,1=公开 */
|
||||
isPublic: string;
|
||||
/** 是否要求提供证件图片;0=无需,1=需要 */
|
||||
isRequireIdCardImg: string;
|
||||
/** 是否要求提供证件号码;0=无需,1=需要 */
|
||||
isRequireIdCardNo: string;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 创建人昵称 */
|
||||
createNickname: string;
|
||||
/** 报名人数 */
|
||||
signupCount: string;
|
||||
/** 退赛人数 */
|
||||
dropoutCount: string;
|
||||
/** 允许报名最大人数 */
|
||||
totalLimit: string;
|
||||
/** 总对阵数 */
|
||||
matchTotalCount: string;
|
||||
/** 已完成对阵数 */
|
||||
matchCount: string;
|
||||
/** 赛事封面url */
|
||||
img: string;
|
||||
/** 活动公告 */
|
||||
notice: string;
|
||||
/** 组别列表,创建时间倒序 */
|
||||
categoryList: InnerCategoryVO[];
|
||||
/** 分组名称列表,格式 "小组名称(淘汰赛或循环赛)" */
|
||||
groupInfoList: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 人员列表(对应 /manager/tm/info/signupPage)
|
||||
// ============================================================
|
||||
|
||||
/** 报名人员 */
|
||||
export interface TournamentAdminInfoSignupVO {
|
||||
/** 选手名称 */
|
||||
realName: string;
|
||||
/** 选手性别;1=男,2=女 */
|
||||
gender: string;
|
||||
/** 选手手机号 */
|
||||
phone: string;
|
||||
/** 选手身份证 */
|
||||
idCard: string;
|
||||
/** 选手身份证图片 */
|
||||
idCardImg: string;
|
||||
/** 选手身份证图片列表 */
|
||||
idCardImgList: string[];
|
||||
}
|
||||
|
||||
/** 人员列表查询参数 */
|
||||
export interface SignupListQueryParams {
|
||||
page: string;
|
||||
limit: string;
|
||||
/** 组别ID(必填) */
|
||||
categoryId: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 赛事规程(对应 /admin/manager/tm/ruleInfo)
|
||||
// ============================================================
|
||||
|
||||
/** 赛事规程详情 */
|
||||
export interface TournamentAdminRuleInfoVO {
|
||||
/** 赛事规程文字内容 */
|
||||
rulesDescr?: string;
|
||||
/** 赛事规程图片 */
|
||||
rulesDescrImg?: string;
|
||||
/** 赛事规程图片列表 */
|
||||
rulesDescrImgList?: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下架(对应 /manager/tm/online)
|
||||
// ============================================================
|
||||
|
||||
/** 上下架请求参数 */
|
||||
export interface ToggleOnlineParams {
|
||||
/** 赛事ID */
|
||||
id: string;
|
||||
/** 0=下架,1=上架 */
|
||||
online: string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { post } from '@/utils/request';
|
||||
import type { LoginResult } from './types';
|
||||
|
||||
export type { LoginResult } from './types';
|
||||
|
||||
export const login = (params: { phone: string; password: string }) => {
|
||||
return post(`/op/login`, params) as Promise<{
|
||||
code: string;
|
||||
msg: string;
|
||||
data: LoginResult;
|
||||
}>;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
phone?: string;
|
||||
nickName?: string;
|
||||
realName?: string;
|
||||
userName?: string;
|
||||
name?: string;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 操作日志 API
|
||||
*/
|
||||
import { get } from '@/utils/request';
|
||||
import type {
|
||||
OperationLogQueryParams,
|
||||
SysOperationLogVO,
|
||||
TournamentAdminOperationPageVO,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
SysOperationLogVO,
|
||||
TournamentAdminOperationPageVO,
|
||||
OperationLogQueryParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /admin/sys/operation/page — 系统操作日志分页 */
|
||||
export async function getOperationLogList(
|
||||
params: OperationLogQueryParams,
|
||||
): Promise<ApiResult<PageData<SysOperationLogVO>>> {
|
||||
return get('/sys/operation/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** GET /admin/tournament/operation/page — 赛事操作日志分页 */
|
||||
export async function getOperationLogs(
|
||||
params: OperationLogQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminOperationPageVO>>> {
|
||||
return get('/tournament/operation/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 getOperationLogList */
|
||||
export const getOperationLogPage = getOperationLogList;
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 操作日志 — 类型定义
|
||||
*/
|
||||
|
||||
/** 系统操作日志列表项(GET /admin/sys/operation/page) */
|
||||
export interface SysOperationLogVO {
|
||||
/** 操作类型:1=下架赛事、2=上架赛事、3=禁用用户、4=启用用户 */
|
||||
type: number;
|
||||
/** 操作人昵称 */
|
||||
nickname: string;
|
||||
/** 操作时间 */
|
||||
createDate: string;
|
||||
/** 操作内容 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 赛事操作日志列表项 */
|
||||
export interface TournamentAdminOperationPageVO {
|
||||
/** 操作类型:1=创建赛事、2=删除赛事、3=取消报名、4=编辑选手、5=对阵管理、6=批量修改组别 */
|
||||
type: string;
|
||||
/** 操作来源:1=PC、2=小程序 */
|
||||
source: string;
|
||||
opName: string;
|
||||
nickname: string;
|
||||
phone: string;
|
||||
tournamentName: string;
|
||||
obj: string;
|
||||
content: string;
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 操作日志列表查询参数 */
|
||||
export interface OperationLogQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 操作类型 */
|
||||
type?: string;
|
||||
/** 系统日志:操作人昵称 */
|
||||
nickname?: string;
|
||||
/** 赛事日志:操作来源 1=PC,2=小程序 */
|
||||
source?: string;
|
||||
/** 赛事日志:操作人昵称 */
|
||||
sourceNickname?: string;
|
||||
/** 赛事日志:操作人手机号 */
|
||||
sourcePhone?: string;
|
||||
/** 赛事日志:赛事名称 */
|
||||
tournamentName?: string;
|
||||
/** 开始日期 yyyy-MM-dd */
|
||||
dateBegin?: string;
|
||||
/** 结束日期 yyyy-MM-dd */
|
||||
dateEnd?: string;
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
import { get } from '@/utils/request';
|
||||
import type { MenuNode, PermissionCode } from '@/types';
|
||||
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
|
||||
import { getAuthDataPromise, setAuthDataPromise, clearAuthDataCache } from '@/api/authCache';
|
||||
import { forceReLogin } from '@/hooks/useAuth';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
export { clearAuthDataCache } from '@/api/authCache';
|
||||
|
||||
/**
|
||||
* 权限数据(角色 + 权限码)
|
||||
*/
|
||||
export interface AuthData {
|
||||
roleList: string[];
|
||||
permsList: string[];
|
||||
nickname: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
const AUTH_FETCH_FAIL_MSG = '账户权限获取失败,请重新登录';
|
||||
|
||||
function normalizeAuthData(res: any): AuthData {
|
||||
if (res?.code != null && res.code != 200) {
|
||||
throw new Error(res.msg || AUTH_FETCH_FAIL_MSG);
|
||||
}
|
||||
|
||||
const data = res?.data || {};
|
||||
const roleList = data.roleList || [];
|
||||
const permsList = data.permsList || [];
|
||||
|
||||
if (permsList.length === 0) {
|
||||
throw new Error(AUTH_FETCH_FAIL_MSG);
|
||||
}
|
||||
|
||||
const nickname = data.nickName || '';
|
||||
const phone = typeof data.phone === 'string' ? data.phone : '';
|
||||
|
||||
if (nickname || phone) {
|
||||
const { setUser } = useUserStore();
|
||||
setUser({
|
||||
...(nickname ? { nickname } : {}),
|
||||
...(phone ? { phone } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return { roleList, permsList, nickname, phone };
|
||||
}
|
||||
|
||||
function handleAuthFetchFailure() {
|
||||
clearAuthDataCache();
|
||||
forceReLogin(AUTH_FETCH_FAIL_MSG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的完整权限数据(角色列表 + 权限编码列表)
|
||||
* 接口: GET /op/permission
|
||||
* Promise 级缓存:并发调用共享同一个请求,失败时清除缓存以允许重试
|
||||
*/
|
||||
export function fetchAuthData(): Promise<AuthData> {
|
||||
const cached = getAuthDataPromise();
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = get('/op/permission')
|
||||
.then((res) => normalizeAuthData(res))
|
||||
.catch((err) => {
|
||||
handleAuthFetchFailure();
|
||||
throw err;
|
||||
});
|
||||
|
||||
setAuthDataPromise(promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的权限编码列表
|
||||
* 复用 fetchAuthData 的缓存,避免重复请求
|
||||
*/
|
||||
export function fetchPermissions(): Promise<PermissionCode[]> {
|
||||
return fetchAuthData().then((data) => data.permsList);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 菜单树 — 基于权限过滤兜底路由
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 按用户拥有的权限码过滤菜单树
|
||||
*
|
||||
* 规则:
|
||||
* - 节点有 permission 字段 → 权限码必须在 permsSet 中才保留
|
||||
* - 节点无 permission 但有 children → 保留并递归过滤子节点
|
||||
* - 节点被过滤且父节点过滤后无子节点、无 component → 父节点也移除
|
||||
*/
|
||||
function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set<string>): MenuNode[] {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
// 叶子节点:有 permission 则按权限过滤
|
||||
if (node.permission) {
|
||||
if (!permsSet.has(node.permission)) return null;
|
||||
return { ...node };
|
||||
}
|
||||
|
||||
// 分组节点(无 permission 有 children):递归过滤子节点
|
||||
if (node.children && node.children.length > 0) {
|
||||
const filteredChildren = filterMenuNodesByPermission(node.children, permsSet);
|
||||
// 子节点全部被过滤且父节点无 component → 移除父节点
|
||||
if (filteredChildren.length === 0 && !node.component) return null;
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
|
||||
// 无 permission、无 children、无 component → 保留(如纯展示节点)
|
||||
return { ...node };
|
||||
})
|
||||
.filter((node): node is MenuNode => node !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的菜单树(基于权限过滤兜底路由)
|
||||
*
|
||||
* 1. 调用 /op/permission 获取用户权限码
|
||||
* 2. 用权限码过滤 FALLBACK_MENU_NODES
|
||||
* 3. 返回过滤后的菜单树
|
||||
*
|
||||
* 权限获取失败时由 fetchAuthData 统一强制重新登录
|
||||
*/
|
||||
export function fetchMenuTree(): Promise<MenuNode[]> {
|
||||
return fetchAuthData().then(({ permsList }) => {
|
||||
const permsSet = new Set(permsList);
|
||||
return filterMenuNodesByPermission(FALLBACK_MENU_NODES, permsSet);
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 订单管理 API
|
||||
* 注意:/admin 前缀已通过 VITE_API_BASE_URL 统一注入(见 .env.*),此文件中只写业务路径。
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
OrderListQueryParams,
|
||||
OrderDetailQueryParams,
|
||||
OrderPlayerListQueryParams,
|
||||
RetryRefundParams,
|
||||
TournamentAdminOrderPageVO,
|
||||
TournamentAdminOrderInfoVO,
|
||||
TournamentAdminOrderInfoPageVO,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
TournamentAdminOrderPageVO,
|
||||
TournamentAdminOrderInfoVO,
|
||||
TournamentAdminOrderInfoPageVO,
|
||||
InnerRefundInfo,
|
||||
OrderListQueryParams,
|
||||
OrderDetailQueryParams,
|
||||
OrderPlayerListQueryParams,
|
||||
RetryRefundParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 订单管理 - 订单分页
|
||||
* GET /admin/manager/order/page → /manager/order/page
|
||||
*/
|
||||
export async function getOrderList(
|
||||
params: OrderListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminOrderPageVO>>> {
|
||||
return get('/manager/order/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单管理 - 订单详情
|
||||
* GET /admin/manager/order/info → /manager/order/info
|
||||
*/
|
||||
export async function getOrderDetail(
|
||||
params: OrderDetailQueryParams,
|
||||
): Promise<ApiResult<TournamentAdminOrderInfoVO>> {
|
||||
return get('/manager/order/info', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单管理 - 订单详情 - 选手列表
|
||||
* GET /admin/manager/order/info/page → /manager/order/info/page
|
||||
*/
|
||||
export async function getOrderPlayerList(
|
||||
params: OrderPlayerListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminOrderInfoPageVO>>> {
|
||||
return get('/manager/order/info/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单管理 - 重新退款
|
||||
* POST /admin/manager/order/retryRefund → /manager/order/retryRefund
|
||||
*/
|
||||
export function retryRefundOrder(
|
||||
params: RetryRefundParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/manager/order/retryRefund', params);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* 订单管理 — 类型定义
|
||||
* 对应 OpenAPI: /admin/manager/order/page
|
||||
*/
|
||||
|
||||
/** 订单列表项 */
|
||||
export interface TournamentAdminOrderPageVO {
|
||||
/** 订单编号 */
|
||||
orderNo: string;
|
||||
/** 选手ID */
|
||||
uniqueId: string;
|
||||
/** 赛事名称 */
|
||||
name: string;
|
||||
/** 选手名称 */
|
||||
realName: string;
|
||||
/** 手机号 */
|
||||
phone: string;
|
||||
/** 报名比赛组别名称 */
|
||||
categoryName: string;
|
||||
/** 报名人名称 */
|
||||
creatorName: string;
|
||||
/** 报名人手机号 */
|
||||
creatorPhone: string;
|
||||
/** 报名人一共报名人数 */
|
||||
creatorSignupCount: number;
|
||||
/** 订单金额;0.00格式 */
|
||||
creatorSignupFee: string;
|
||||
/** 优惠金额;0.00格式 */
|
||||
creatorSignupFeeDiscount: string;
|
||||
/** 实付金额;0.00格式 */
|
||||
creatorSignupFeeActual: string;
|
||||
/** 退款金额;0.00格式 */
|
||||
creatorSignupFeeRefund: string;
|
||||
/** 订单时间;yyyy.MM.dd HH:mm:ss */
|
||||
orderTime: string;
|
||||
/** 支付时间;yyyy.MM.dd HH:mm:ss */
|
||||
payTime: string;
|
||||
/** 订单状态;1=已支付,2=部分退款,3=全部退款,4=已关闭,-1=其他 */
|
||||
orderStatus: string;
|
||||
/** 退款状态;1=无退款,2=退款成功,3=退款失败,-1=其他 */
|
||||
refundStatus: string;
|
||||
}
|
||||
|
||||
/** 订单列表查询参数 */
|
||||
export interface OrderListQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 订单时间开始 yyyy-MM-dd */
|
||||
orderDate?: string;
|
||||
/** 订单时间结束 yyyy-MM-dd */
|
||||
orderDateEnd?: string;
|
||||
/** 订单编号 */
|
||||
orderNo?: string;
|
||||
/** 赛事名称 */
|
||||
tName?: string;
|
||||
/** 选手名称 */
|
||||
sName?: string;
|
||||
/**
|
||||
* 订单状态
|
||||
* 不传=全部、1=支付成功,2=部分退款,3=全部退款,4=超时关闭
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 退款状态
|
||||
* 不传=全部、2=退款成功,3=退款失败
|
||||
*/
|
||||
refundStatus?: string;
|
||||
}
|
||||
|
||||
/** 订单分页数据(含列表统计字段) */
|
||||
export interface OrderPageData extends PageData<TournamentAdminOrderPageVO> {
|
||||
/** 扩展数据 */
|
||||
exData: {
|
||||
/** 总订单金额(已支付) */
|
||||
totalOrderAmount: string;
|
||||
/** 总实付金额 */
|
||||
totalActualAmount: string;
|
||||
/** 总退款金额 */
|
||||
totalRefundAmount: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 统一响应包裹 */
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 订单详情(对应 /manager/order/info)
|
||||
// ============================================================
|
||||
|
||||
/** 退款记录 */
|
||||
export interface InnerRefundInfo {
|
||||
/** 退款金额;0.00格式 */
|
||||
refundAmount: string;
|
||||
/** 退款方式 */
|
||||
refundMethod: string;
|
||||
/** 退款申请时间 */
|
||||
refundApplyTime: string;
|
||||
/** 第三方退款单号 */
|
||||
refundOutTradeNo: string;
|
||||
/** 退款完成时间 */
|
||||
refundTime: string;
|
||||
/** 退款状态;1=退款中,2=退款成功,3=退款失败 */
|
||||
refundStatus: string;
|
||||
/** 退款选手名称合集文字 */
|
||||
refundSignupNames: string;
|
||||
}
|
||||
|
||||
/** 订单详情 */
|
||||
export interface TournamentAdminOrderInfoVO {
|
||||
/** 赛事名称 */
|
||||
tournamentName: string;
|
||||
/** 订单编号 */
|
||||
orderNumber: string;
|
||||
/** 订单时间 */
|
||||
orderTime: string;
|
||||
/** 支付时间 */
|
||||
payTime: string;
|
||||
/** 订单金额;0.00格式 */
|
||||
orderAmount: string;
|
||||
/** 优惠金额;0.00格式 */
|
||||
discountAmount: string;
|
||||
/** 实付金额;0.00格式 */
|
||||
payAmount: string;
|
||||
/** 退款金额;0.00格式 */
|
||||
refundAmount: string;
|
||||
/** 退款信息list */
|
||||
refundInfoList: InnerRefundInfo[];
|
||||
}
|
||||
|
||||
/** 订单详情查询参数 */
|
||||
export interface OrderDetailQueryParams {
|
||||
/** 订单编号 */
|
||||
orderNo?: string;
|
||||
/** 选手ID */
|
||||
uniqueId?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 订单详情 — 选手列表(对应 /manager/order/info/page)
|
||||
// ============================================================
|
||||
|
||||
/** 订单下选手信息 */
|
||||
export interface TournamentAdminOrderInfoPageVO {
|
||||
/** 姓名 */
|
||||
realName: string;
|
||||
/** 性别;1=男,2=女(接口实际返回 string) */
|
||||
gender: string;
|
||||
/** 手机号 */
|
||||
phone: string;
|
||||
/** 证件号 */
|
||||
idCard: string;
|
||||
/** 证件号图片 */
|
||||
idCardImg: string;
|
||||
/** 选手身份证图片列表 */
|
||||
idCardImgList: string[];
|
||||
/** 选手组别名称 */
|
||||
categoryName: string;
|
||||
/** 订单金额;0.00 */
|
||||
orderAmount: string;
|
||||
/** 优惠金额;0.00 */
|
||||
discountAmount: string;
|
||||
/** 实付金额;0.00 */
|
||||
payAmount: string;
|
||||
/** 退款金额;0.00 */
|
||||
refundAmount: string;
|
||||
}
|
||||
|
||||
/** 选手列表查询参数 */
|
||||
export interface OrderPlayerListQueryParams {
|
||||
/** 订单编号 */
|
||||
orderNo?: string;
|
||||
/** 选手ID */
|
||||
uniqueId?: string;
|
||||
page: string;
|
||||
limit: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 重新退款(对应 /manager/order/retryRefund)
|
||||
// ============================================================
|
||||
|
||||
/** 重新退款请求参数 */
|
||||
export interface RetryRefundParams {
|
||||
/** 支付单号 */
|
||||
payOrderNo: string;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 支付流水 API
|
||||
* 对应 OpenAPI: GET /admin/finance/paymentFlow/page
|
||||
*/
|
||||
import { get } from '@/utils/request';
|
||||
import type {
|
||||
PaymentFlowQueryParams,
|
||||
PaymentFlowVO,
|
||||
PaymentFlowPageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
PaymentFlowVO,
|
||||
PaymentFlowQueryParams,
|
||||
PaymentFlowPageData,
|
||||
PaymentFlowExData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /admin/finance/paymentFlow/page — 支付流水分页 */
|
||||
export async function getPaymentFlowList(
|
||||
params: PaymentFlowQueryParams,
|
||||
): Promise<ApiResult<PaymentFlowPageData>> {
|
||||
return get('/finance/paymentFlow/page', params as Record<string, any>);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 支付流水 — 类型定义
|
||||
* 对应 OpenAPI: GET /admin/finance/paymentFlow/page
|
||||
*/
|
||||
|
||||
/** 支付流水列表项 */
|
||||
export interface PaymentFlowVO {
|
||||
/** 流水号 */
|
||||
serialNo: string;
|
||||
/** 订单编号 */
|
||||
orderNo: string;
|
||||
/** 类型:1=报名,2=取消报名 */
|
||||
type: number;
|
||||
/** 用户昵称 */
|
||||
nickname: string;
|
||||
/** 用户手机�� */
|
||||
phone: string;
|
||||
/** 订单金额("0.00"格式) */
|
||||
amount: string;
|
||||
/** 订单创建时间 */
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 支付流水查询参数 */
|
||||
export interface PaymentFlowQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 订单创建开始时间 yyyy-MM-dd */
|
||||
createDateBegin?: string;
|
||||
/** 订单创建结束时间 yyyy-MM-dd */
|
||||
createDateEnd?: string;
|
||||
/** 用户昵称 */
|
||||
nickname?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 类型:1/2 */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 支付流水分页扩展汇总(与 total 同级) */
|
||||
export interface PaymentFlowExData {
|
||||
/** 总支付金额 */
|
||||
totalAmount: string;
|
||||
/** 总退款金额 */
|
||||
totalRefundAmount: string;
|
||||
}
|
||||
|
||||
/** 支付流水分页响应 */
|
||||
export interface PaymentFlowPageData {
|
||||
total: number;
|
||||
list: PaymentFlowVO[];
|
||||
exData: PaymentFlowExData;
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 财务报表 API
|
||||
* 对应 OpenAPI: GET /admin/finance/report/list
|
||||
*/
|
||||
import { get } from '@/utils/request';
|
||||
import type { ReportQueryParams, FinanceReportVO, ApiResult } from './types';
|
||||
|
||||
export type { ReportQueryParams, FinanceReportVO, ReportMonthItem, ApiResult } from './types';
|
||||
|
||||
/** GET /admin/finance/report/list — 财务报表 */
|
||||
export function getFinanceReport(params: ReportQueryParams): Promise<ApiResult<FinanceReportVO>> {
|
||||
return get('/admin/finance/report/list', params as Record<string, any>);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 财务报表 — 类型定义
|
||||
* 对应 OpenAPI: GET /admin/finance/report/list
|
||||
*/
|
||||
|
||||
/** 月度明细项(对应 InnerMonthVO) */
|
||||
export interface ReportMonthItem {
|
||||
/** 月份 yyyy-MM */
|
||||
month: string;
|
||||
/** 订单金额,0.00 格式 */
|
||||
orderAmount: string;
|
||||
/** 退款金额,0.00 格式 */
|
||||
refundAmount: string;
|
||||
/** 净收入 = orderAmount - refundAmount,0.00 格式 */
|
||||
netIncome: string;
|
||||
/** 提现金额,0.00 格式 */
|
||||
withdrawAmount: string;
|
||||
}
|
||||
|
||||
/** 财务报表汇总(对应 TournamentFinanceReportVO) */
|
||||
export interface FinanceReportVO {
|
||||
/** 总收入(扣除退款后),0.00 格式 */
|
||||
totalIncome: string;
|
||||
/** 总收入较上月增长率 */
|
||||
totalIncomeTendency: string;
|
||||
/** 总提现金额,0.00 格式 */
|
||||
totalWithdrawIncome: string;
|
||||
/** 总提现金额较上月增长率 */
|
||||
totalWithdrawIncomeTendency: string;
|
||||
/** 平台余额 = totalIncome - totalWithdrawIncome,0.00 格式 */
|
||||
totalBalanceAmount: string;
|
||||
/** 月度收支明细 */
|
||||
monthList: ReportMonthItem[];
|
||||
}
|
||||
|
||||
/** 财务报表查询参数 */
|
||||
export interface ReportQueryParams {
|
||||
/** 起始月份 yyyy-MM */
|
||||
beginDate: string;
|
||||
/** 结束月份 yyyy-MM */
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 角色管理 API
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
RoleListQueryParams,
|
||||
RoleUsageUserQueryParams,
|
||||
TournamentAdminRolePageVO,
|
||||
RoleUsageUserVO,
|
||||
PermissionTreeNode,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
TournamentAdminRolePageVO,
|
||||
RoleUsageUserVO,
|
||||
RoleListQueryParams,
|
||||
RoleUsageUserQueryParams,
|
||||
PermissionTreeNode,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /sys/role/page — 角色列表分页 */
|
||||
export async function getRoleList(
|
||||
params: RoleListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminRolePageVO>>> {
|
||||
return get('/admin/sys/role/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** GET /sys/role/usageUserPage — 角色下用户列表分页 */
|
||||
export async function getRoleUsageUserList(
|
||||
params: RoleUsageUserQueryParams,
|
||||
): Promise<ApiResult<PageData<RoleUsageUserVO>>> {
|
||||
return get('/sys/role/usageUserPage', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** GET /admin/sys/role/permissionList?roleId=xxx — 角色权限树 */
|
||||
export async function getPermissionTree(
|
||||
roleId: number | string,
|
||||
): Promise<ApiResult<PermissionTreeNode[]>> {
|
||||
return get('/sys/role/permissionList', { roleId: String(roleId) });
|
||||
}
|
||||
|
||||
/** POST /admin/sys/role/del — 删除角色 */
|
||||
export async function deleteRole(id: number): Promise<ApiResult<null>> {
|
||||
return post('/sys/role/del', { id });
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 角色管理 — 类型定义
|
||||
* 对应 OpenAPI: /admin/sys/role/*
|
||||
*/
|
||||
|
||||
/** 角色列表项 */
|
||||
export interface TournamentAdminRolePageVO {
|
||||
/** 角色ID */
|
||||
roleId: number;
|
||||
/** 角色名称 */
|
||||
roleName: string;
|
||||
/** 角色用户数量 */
|
||||
userCount: number;
|
||||
/** 角色创建时间 */
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
/** 角色列表查询参数 */
|
||||
export interface RoleListQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 角色名称(模糊搜索) */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 角色下用户列表项 */
|
||||
export interface RoleUsageUserVO {
|
||||
realName: string;
|
||||
phone: string;
|
||||
createTime: string;
|
||||
/** 状态: 0=停用、1=正常 */
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** 角色下用户列表查询参数 */
|
||||
export interface RoleUsageUserQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 用户姓名或手机号 */
|
||||
text?: string;
|
||||
roleId?: string;
|
||||
}
|
||||
|
||||
/** 保存角色 — POST /admin/sys/role/save */
|
||||
export interface RoleSaveParams {
|
||||
name: string;
|
||||
menuIds: string[];
|
||||
}
|
||||
|
||||
/** 更新角色 — POST /admin/sys/role/update */
|
||||
export interface RoleUpdateParams {
|
||||
id: string;
|
||||
name: string;
|
||||
menuIds: string[];
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 权限树节点(后端 /admin/sys/role/permissionList 返回) */
|
||||
export interface PermissionTreeNode {
|
||||
/** 主键 */
|
||||
id: number;
|
||||
/** 上级ID */
|
||||
pid: number;
|
||||
/** 标题 */
|
||||
name: string;
|
||||
/** 是否选中:true=是,false=否,null=空(仅叶节点有意义,父节点遵循级联逻辑) */
|
||||
selected: boolean | null;
|
||||
/** 子节点 */
|
||||
children?: PermissionTreeNode[];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { get } from '@/utils/request';
|
||||
import type { OssUploadTypeValue } from '@/utils/oss';
|
||||
|
||||
/** OSS 签名数据 */
|
||||
export interface OssSignData {
|
||||
host: string;
|
||||
dir: string;
|
||||
policy: string;
|
||||
signature: string;
|
||||
x_oss_credential: string;
|
||||
x_oss_date: string;
|
||||
security_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 上传签名
|
||||
* @param type 上传类型(参见 OssUploadType)
|
||||
*/
|
||||
export async function fetchOssSign(type: OssUploadTypeValue): Promise<OssSignData> {
|
||||
// 后端签名接口地址(待对接时调整)
|
||||
const res: any = await get('/sys/oss/getUploadSign', { type });
|
||||
const data = res?.data as OssSignData;
|
||||
if (!data?.host) {
|
||||
throw new Error(res?.msg || '获取上传凭证失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 用户管理 API
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
UserListQueryParams,
|
||||
UserSaveParams,
|
||||
UserUpdateParams,
|
||||
UserActiveParams,
|
||||
UserUpdatePwdParams,
|
||||
TournamentAdminUserVO,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
TournamentAdminUserVO,
|
||||
UserListQueryParams,
|
||||
UserSaveParams,
|
||||
UserUpdateParams,
|
||||
UserActiveParams,
|
||||
UserUpdatePwdParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /admin/sys/user/page — 用户分页 */
|
||||
export async function getUserList(
|
||||
params: UserListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminUserVO>>> {
|
||||
return get('/admin/sys/user/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** POST /admin/sys/user/save — 新增用户 */
|
||||
export function saveUser(params: UserSaveParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/admin/sys/user/save', params);
|
||||
}
|
||||
|
||||
/** POST /admin/sys/user/update — 更新用户 */
|
||||
export function updateUser(params: UserUpdateParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/admin/sys/user/update', params);
|
||||
}
|
||||
|
||||
/** POST /sys/user/active — 启用/禁用(直接调接口) */
|
||||
export function toggleUserActive(
|
||||
params: UserActiveParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/admin/sys/user/active', params);
|
||||
}
|
||||
|
||||
/** POST /sys/user/updatepwd — 修改密码(直接调接口) */
|
||||
export function updateUserPwd(
|
||||
params: UserUpdatePwdParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post('/admin/sys/user/updatepwd', params);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 用户管理 — 类型定义
|
||||
* 对应 OpenAPI: /admin/sys/user/*
|
||||
*/
|
||||
|
||||
export interface TournamentAdminUserVO {
|
||||
id: string;
|
||||
/** 用户姓名 */
|
||||
realName: string;
|
||||
phone: string;
|
||||
/** 角色名称 */
|
||||
roleName: string;
|
||||
/** 角色ID(编辑时需要回显,列表可能不返回) */
|
||||
roleId?: string;
|
||||
/** 状态;0=停用、1=正常(接口实际返回 string) */
|
||||
status: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface UserListQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 用户姓名或手机号 */
|
||||
text?: string;
|
||||
/** 角色ID */
|
||||
roleId?: string;
|
||||
/** 状态;不传=全部、0=停用、1=正常 */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** 新增用户 — POST /admin/sys/user/save */
|
||||
export interface UserSaveParams {
|
||||
realName: string;
|
||||
phone: string;
|
||||
roleId: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/** 更新用户 — POST /admin/sys/user/update */
|
||||
export interface UserUpdateParams {
|
||||
id: string;
|
||||
realName: string;
|
||||
phone: string;
|
||||
roleId: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}
|
||||
|
||||
/** 启用/禁用 — POST /admin/sys/user/active */
|
||||
export interface UserActiveParams {
|
||||
userId: string;
|
||||
/** 0=禁用、1=启用 */
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** 修改密码 — POST /admin/sys/user/updatepwd */
|
||||
export interface UserUpdatePwdParams {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 用户钱包 API
|
||||
* OpenAPI: GET /admin/finance/wallet/summary, GET /admin/finance/wallet/page
|
||||
*/
|
||||
import { get } from '@/utils/request';
|
||||
import type {
|
||||
WalletQueryParams,
|
||||
WalletSummaryVO,
|
||||
WalletSummaryPageData,
|
||||
WalletTransactionQueryParams,
|
||||
WalletTransactionVO,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
WalletSummaryVO,
|
||||
WalletSummaryPageData,
|
||||
WalletQueryParams,
|
||||
WalletTransactionVO,
|
||||
WalletTransactionQueryParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
/** GET /admin/finance/wallet/summary — 钱包列表分页 */
|
||||
export async function getWalletList(
|
||||
params: WalletQueryParams,
|
||||
): Promise<ApiResult<WalletSummaryPageData>> {
|
||||
return get('/finance/wallet/summary', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** GET /admin/finance/wallet/page — 交易明细分页 */
|
||||
export async function getWalletTransactionList(
|
||||
params: WalletTransactionQueryParams,
|
||||
): Promise<ApiResult<PageData<WalletTransactionVO>>> {
|
||||
return get('/finance/wallet/page', params as Record<string, any>);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 用户钱包 — 类型定义
|
||||
* 对应 OpenAPI: GET /admin/finance/wallet/summary
|
||||
*/
|
||||
|
||||
/** 钱包列表项 */
|
||||
export interface WalletSummaryVO {
|
||||
/** 用户ID */
|
||||
userId: number;
|
||||
/** 用户昵称 */
|
||||
userNickname: string;
|
||||
/** 手机号 */
|
||||
phone: string;
|
||||
/** 余额("0.00"格式) */
|
||||
amount: string;
|
||||
/** 冻结金额("0.00"格式) */
|
||||
frozenAmount: string;
|
||||
/** 累计成功提现金额("0.00"格式) */
|
||||
withdrawalAmount: string;
|
||||
}
|
||||
|
||||
/** 钱包汇总扩展数据(与 total 同级) */
|
||||
export interface WalletSummaryExData {
|
||||
/** 总余额 */
|
||||
totalAmount: string;
|
||||
/** 总冻结金额 */
|
||||
totalFrozenAmount: string;
|
||||
/** 总累计提现 */
|
||||
totalWithdrawalAmount: string;
|
||||
}
|
||||
|
||||
/** 钱包列表分页响应 */
|
||||
export interface WalletSummaryPageData {
|
||||
total: number;
|
||||
list: WalletSummaryVO[];
|
||||
exData: WalletSummaryExData;
|
||||
}
|
||||
|
||||
/** 钱包列表查询参数 */
|
||||
export interface WalletQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 用户昵称,模糊搜索 */
|
||||
nickname?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 余额最小值 */
|
||||
amountBegin?: string;
|
||||
/** 余额最大值 */
|
||||
amountEnd?: string;
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 交易明细(GET /admin/finance/wallet/page)
|
||||
// ============================================================
|
||||
|
||||
/** 交易明细列表项 */
|
||||
export interface WalletTransactionVO {
|
||||
/** 类型:1=报名入账,2=提现出账,3=退款出账 */
|
||||
type: number;
|
||||
/** 金额("0.00"格式,入账为+,出账为-) */
|
||||
amount: string;
|
||||
/** 余额("0.00"格式) */
|
||||
afterAmount: string;
|
||||
/** 可提现余额("0.00"格式) */
|
||||
withdrawAmount: string;
|
||||
/** 冻结金额("0.00"格式) */
|
||||
freezeAmount: string;
|
||||
/** 冻结时间 */
|
||||
freezeTime: string;
|
||||
/** 解冻时间 */
|
||||
unfreezeTime: string;
|
||||
/** 关联单号 */
|
||||
orderNo: string;
|
||||
/** 时间 */
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 交易明细查询参数 */
|
||||
export interface WalletTransactionQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 用户ID */
|
||||
userId?: string;
|
||||
/** 创建开始时间 yyyy-MM-dd */
|
||||
createDateBegin?: string;
|
||||
/** 创建结束时间 yyyy-MM-dd */
|
||||
createDateEnd?: string;
|
||||
/** 类型:1/2/3 */
|
||||
type?: string;
|
||||
/** 状态:1=已解冻,2=冻结中,3=正常 */
|
||||
status?: string;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 提现管理 API
|
||||
* 对应 OpenAPI: GET /admin/finance/withdraw/page, GET /admin/finance/withdraw/info
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type { WithdrawQueryParams, WithdrawVO, WithdrawInfoVO, PageData, ApiResult } from './types';
|
||||
|
||||
export type { WithdrawVO, WithdrawInfoVO, WithdrawQueryParams, PageData, ApiResult } from './types';
|
||||
|
||||
/** GET /admin/finance/withdraw/page — 提现列表分页 */
|
||||
export async function getWithdrawList(
|
||||
params: WithdrawQueryParams,
|
||||
): Promise<ApiResult<PageData<WithdrawVO>>> {
|
||||
return get('/admin/finance/withdraw/page', params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** GET /admin/finance/withdraw/info?id=xxx — 提现详情 */
|
||||
export async function getWithdrawInfo(id: number): Promise<ApiResult<WithdrawInfoVO>> {
|
||||
return get('/admin/finance/withdraw/info', { id: String(id) });
|
||||
}
|
||||
|
||||
/** 提现审核请求参数 */
|
||||
export interface WithdrawAuditParams {
|
||||
/** 提现ID */
|
||||
id: string;
|
||||
/** 审核结果:1=通过,2=驳回 */
|
||||
audit: string;
|
||||
/** 审核内容 */
|
||||
checkMsg: string;
|
||||
/** 审核图片(逗号拼接的 URL) */
|
||||
checkImg: string;
|
||||
}
|
||||
|
||||
/** POST /admin/finance/withdraw/audit — 审核提现 */
|
||||
export async function postWithdrawAudit(params: WithdrawAuditParams): Promise<ApiResult<null>> {
|
||||
return post('/admin/finance/withdraw/audit', params);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* 提现管理 — 类型定义
|
||||
* 对应 OpenAPI: GET /admin/finance/withdraw/page
|
||||
*/
|
||||
|
||||
/** 提现列表项(对齐 TournamentFinanceWithdrawVO) */
|
||||
export interface WithdrawVO {
|
||||
/** 提现ID */
|
||||
id: number;
|
||||
/** 申请人昵称 */
|
||||
nickname: string;
|
||||
/** 申请人手机号 */
|
||||
phone: string;
|
||||
/** 申请人真实姓名 */
|
||||
realName: string;
|
||||
/** 提现类型(固定"银行卡") */
|
||||
withdrawType: string;
|
||||
/** 提现金额("0.00"格式) */
|
||||
withdrawAmount: string;
|
||||
/** 到账金额("0.00"格式) */
|
||||
receivedAmount: string;
|
||||
/** 审核状态:0=待审核,1=审核通过,2=审核驳回 */
|
||||
auditStatus: number;
|
||||
/** 到账状态:0=未支付,1=支付成功,2=支付失败 */
|
||||
payStatus: number;
|
||||
/** 第三方单号 */
|
||||
serialNumber: string;
|
||||
/** 申请时间 */
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 提现列表查询参数 */
|
||||
export interface WithdrawQueryParams {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
/** 提现申请人昵称 */
|
||||
nickname?: string;
|
||||
/** 提现申请人手机号 */
|
||||
phone?: string;
|
||||
/** 提现申请人真实姓名 */
|
||||
realName?: string;
|
||||
/** 审核状态:0/1/2 */
|
||||
auditStatus?: string;
|
||||
/** 到账状态:0/1/2 */
|
||||
payStatus?: string;
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 提现详情(对齐 TournamentFinanceWithdrawInfoVO) */
|
||||
export interface WithdrawInfoVO {
|
||||
/** 申请人昵称 */
|
||||
nickname: string;
|
||||
/** 申请人手机号 */
|
||||
phone: string;
|
||||
/** 申请人真实姓名 */
|
||||
realName: string;
|
||||
/** 申请时间 */
|
||||
createDate: string;
|
||||
/** 提现类型(固定"银行卡") */
|
||||
withdrawType: string;
|
||||
/** 提现金额("0.00"格式) */
|
||||
withdrawAmount: string;
|
||||
/** 费率 */
|
||||
feeRate: string;
|
||||
/** 手续费 */
|
||||
feeAmount: string;
|
||||
/** 到账金额("0.00"格式) */
|
||||
receivedAmount: string;
|
||||
/** 审核状态:0=待审核,1=审核通过,2=审核驳回 */
|
||||
auditStatus: number;
|
||||
/** 到账状态:0=未支付,1=支付成功,2=支付失败 */
|
||||
payStatus: number;
|
||||
/** 银行卡类型:1=个人 */
|
||||
bankCardType: number;
|
||||
/** 持卡人真实姓名 */
|
||||
cardHolderName: string;
|
||||
/** 银行卡号 */
|
||||
bankCardNumber: string;
|
||||
/** 开户行名称 */
|
||||
bankName: string;
|
||||
/** 支行名称 */
|
||||
branchName: string;
|
||||
/** 支付时间 */
|
||||
payTime: string;
|
||||
/** 订单号 */
|
||||
orderNo: string;
|
||||
/** 审核时间 */
|
||||
auditDate: string;
|
||||
/** 审核人昵称 */
|
||||
auditNickname: string;
|
||||
/** 审核图片 */
|
||||
auditImg: string;
|
||||
/** 审核图片列表 */
|
||||
auditImgList: string[];
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/* 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
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"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
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784966818945" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1689" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M424.19 675.833l64.823 64.823 229.869-229.869-229.869-229.869-64.823 64.823 118.612 119.072H98.236v91.948h444.567L424.19 675.833zM833.817 97.022H190.183c-51.031 0-91.948 41.376-91.948 91.948v183.895h91.948V188.97h643.633v643.633H190.183V648.708H98.236v183.895c0 50.571 40.917 91.948 91.948 91.948h643.633c50.571 0 91.948-41.376 91.948-91.948V188.97c-0.001-50.571-41.377-91.948-91.948-91.948z" p-id="1690"></path></svg>
|
||||
|
Before Width: | Height: | Size: 749 B |
@@ -56,90 +56,12 @@ a {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ===== 页面布局公共类 =====
|
||||
|
||||
// 页面容器:纵向 flex,占满高度;筛选区与表格区作为直接子元素
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 页面筛选区域:横向 flex,可换行
|
||||
.page-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// 筛选区域操作按钮组:横向 flex
|
||||
.page-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// 表格显示区域:纵向 flex,占据剩余高度。
|
||||
// overflow: hidden 防止子元素撑开容器,滚动由 Table 的 scroll.x/y 接管。
|
||||
// 分页组件作为独立子元素置于 Table 下方,不占用 Table 的 scroll 空间。
|
||||
.page-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
margin-top: 16px;
|
||||
.match-ellipsis-single {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.page-pagination {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
// ===== antd 组件覆盖 =====
|
||||
|
||||
// Tag 默认 margin-right: 8px,会挤开表格列或卡片内间距,统一去掉
|
||||
.ant-tag {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
// ===== 页面切换动画(KeepAlive + RouterView) =====
|
||||
|
||||
.page-fade-slide-enter-active,
|
||||
.page-fade-slide-leave-active {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
transition:
|
||||
opacity 0.24s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.24s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.page-fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
.page-fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
.page-fade-slide-leave-active {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
.ant-layout .ant-layout-header {
|
||||
padding-inline: 15px;
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// 页面布局根容器:筛选区 + 表格区作为直接子元素
|
||||
.containerMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
// 页面筛选区域:横向 flex,可换行
|
||||
.filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// 筛选区域操作按钮组
|
||||
.filterActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// 表格显示区域:纵向 flex,占据剩余高度
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
|
||||
.tableBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container,
|
||||
.ant-table,
|
||||
.ant-table-container {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格内可点击数字链接(等宽数字,避免列宽抖动)
|
||||
.tabularLink {
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// ── 汇总 ──
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 32px;
|
||||
flex-shrink: 0;
|
||||
padding: 14px 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 二次确认弹窗 =====
|
||||
|
||||
.confirmContent {
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
strong {
|
||||
margin: 0 4px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.confirmTip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.toggleConfirmModal),
|
||||
:global(.deleteConfirmModal) {
|
||||
.ant-modal-confirm-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 修改密码弹窗 - 表单域控制器
|
||||
*
|
||||
* 集中管理弹窗内的所有规则与提示文案。
|
||||
*/
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import { isValidPassword } from '@/utils/form';
|
||||
|
||||
// ============================================================
|
||||
// 文案
|
||||
// ============================================================
|
||||
|
||||
/** 弹窗底部提示 */
|
||||
export const TIPS_TEXT = '修改后立即生效,请妥善保管新密码';
|
||||
|
||||
// ============================================================
|
||||
// 表单规则
|
||||
// ============================================================
|
||||
|
||||
/** 新密码规则:必填 + 至少 8 位 + 字母 + 数字 */
|
||||
export const newPasswordRules: Rule[] = [
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
if (!isValidPassword(value)) {
|
||||
return Promise.reject(new Error('密码至少8位,需包含字母与数字'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 确认密码规则:必填 + 必须与新密码一致 */
|
||||
export function confirmNewPasswordRules(getPassword: () => string): Rule[] {
|
||||
return [
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
{
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
if (value !== getPassword()) {
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { defineComponent, ref, reactive, nextTick } from 'vue';
|
||||
import { useEffect } from '@/hooks';
|
||||
import { Modal, Form, Input, Button, message } from 'ant-design-vue';
|
||||
import { TIPS_TEXT, newPasswordRules, confirmNewPasswordRules } from './controller';
|
||||
import styles from './style.module.less';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
visible: boolean;
|
||||
submitting?: boolean;
|
||||
onClose: () => void;
|
||||
/** 提交回调:父组件负责调用 API,loading 状态由 submitting 控制 */
|
||||
onSave: (payload: { newPassword: string }) => void;
|
||||
}
|
||||
|
||||
/** 默认表单数据 */
|
||||
const getDefaultForm = () => ({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ChangePasswordModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
submitting: { type: Boolean, default: false },
|
||||
onClose: { type: Function, required: true },
|
||||
onSave: { type: Function, required: true },
|
||||
},
|
||||
setup(props: ChangePasswordModalProps) {
|
||||
const formRef = ref<any>();
|
||||
const formData = reactive(getDefaultForm());
|
||||
|
||||
/** 监听 visible 重置表单 */
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
Object.assign(formData, getDefaultForm());
|
||||
nextTick().then(() => formRef.value?.clearValidate());
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
/** 提交 */
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
props.onSave({ newPassword: formData.newPassword });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
title="修改密码"
|
||||
visible={props.visible}
|
||||
onCancel={handleClose}
|
||||
width={420}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
centered
|
||||
wrapClassName={styles.changePasswordModalMain}
|
||||
class={styles.modal}
|
||||
>
|
||||
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
|
||||
<Form.Item label="新密码" name="newPassword" rules={newPasswordRules}>
|
||||
<Input.Password
|
||||
value={formData.newPassword}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.newPassword = val;
|
||||
}}
|
||||
placeholder="至少8位,含字母+数字"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirmPassword"
|
||||
rules={confirmNewPasswordRules(() => formData.newPassword)}
|
||||
>
|
||||
<Input.Password
|
||||
value={formData.confirmPassword}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.confirmPassword = val;
|
||||
}}
|
||||
placeholder="再次输入新密码"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div class={styles.tip}>{TIPS_TEXT}</div>
|
||||
</Form>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
||||
确认修改
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
.changePasswordModalMain {
|
||||
.modal {
|
||||
:global {
|
||||
.ant-modal-body {
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
:global {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ant-form-item-explain-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 24px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
.helloWorldMain {
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1677ff;
|
||||
}
|
||||
.title {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.desc {
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.desc {
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
return () => (
|
||||
<div class={styles.helloWorldMain}>
|
||||
<div class={styles.container}>
|
||||
<h3 class={styles.title}>{props.msg}</h3>
|
||||
<p class={styles.desc}>
|
||||
这是一个公共组件示例,采用 TSX 写法。你可以在
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
.statusTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&.dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: #1677ff;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-color: rgba(22, 119, 255, 0.18);
|
||||
|
||||
.dot {
|
||||
background: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #389e0d;
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
border-color: rgba(82, 196, 26, 0.22);
|
||||
|
||||
.dot {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #d48806;
|
||||
background: rgba(250, 173, 20, 0.12);
|
||||
border-color: rgba(250, 173, 20, 0.28);
|
||||
|
||||
.dot {
|
||||
background: #faad14;
|
||||
box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #cf1322;
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
border-color: rgba(255, 77, 79, 0.2);
|
||||
|
||||
.dot {
|
||||
background: #ff4d4f;
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.orange {
|
||||
color: #d46b08;
|
||||
background: rgba(250, 140, 22, 0.1);
|
||||
border-color: rgba(250, 140, 22, 0.22);
|
||||
|
||||
.dot {
|
||||
background: #fa8c16;
|
||||
box-shadow: 0 0 0 2px rgba(250, 140, 22, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.cyan {
|
||||
color: #08979c;
|
||||
background: rgba(19, 194, 194, 0.1);
|
||||
border-color: rgba(19, 194, 194, 0.22);
|
||||
|
||||
.dot {
|
||||
background: #13c2c2;
|
||||
box-shadow: 0 0 0 2px rgba(19, 194, 194, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.purple {
|
||||
color: #531dab;
|
||||
background: rgba(114, 46, 209, 0.08);
|
||||
border-color: rgba(114, 46, 209, 0.2);
|
||||
|
||||
.dot {
|
||||
background: #722ed1;
|
||||
box-shadow: 0 0 0 2px rgba(114, 46, 209, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
.dot {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.default {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: #fafafa;
|
||||
border-color: #f0f0f0;
|
||||
|
||||
.dot {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import styles from './StatusTag.module.less';
|
||||
|
||||
export type StatusTagTone =
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'orange'
|
||||
| 'cyan'
|
||||
| 'purple'
|
||||
| 'neutral'
|
||||
| 'default';
|
||||
|
||||
const StatusTag = defineComponent({
|
||||
name: 'StatusTag',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
tone: { type: String as PropType<StatusTagTone>, default: 'default' },
|
||||
showDot: { type: Boolean, default: true },
|
||||
dimmed: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => (
|
||||
<span
|
||||
class={[styles.statusTag, styles[props.tone], props.dimmed && styles.dimmed]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{props.showDot ? <span class={styles.dot} /> : null}
|
||||
<span>{props.label}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default StatusTag;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as StatusTag } from './StatusTag';
|
||||
export type { StatusTagTone } from './StatusTag';
|
||||
@@ -3,7 +3,3 @@
|
||||
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
|
||||
*/
|
||||
export { default as HelloWorld } from './HelloWorld';
|
||||
export { default as ChangePasswordModal } from './ChangePasswordModal';
|
||||
export { default as PageTabs } from './pageTabs';
|
||||
export { StatusTag } from './StatusTag';
|
||||
export type { StatusTagTone } from './StatusTag';
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
type PropType,
|
||||
type VNode,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { Dropdown, Menu, message } from 'ant-design-vue';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||
import type { IconType } from 'vue-icons-plus/lib';
|
||||
import {
|
||||
LuCopyX,
|
||||
LuListX,
|
||||
LuMoreHorizontal,
|
||||
LuPanelLeftClose,
|
||||
LuPanelRightClose,
|
||||
} from 'vue-icons-plus/lu';
|
||||
import type { TabView } from '@/types';
|
||||
import { useState } from '@/hooks';
|
||||
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||
import styles from './style.module.less';
|
||||
|
||||
const SCROLL_STEP = 240;
|
||||
|
||||
type TabKeyHandler = (key: string) => void;
|
||||
type TabBatchHandler = (key: string) => void;
|
||||
type TabAllHandler = () => void;
|
||||
|
||||
const renderAiIcon = (Icon: IconType, size = 16) => h(Icon, { size });
|
||||
|
||||
/**
|
||||
* Chrome 风格的页面标签栏组件
|
||||
* - 无滚动条,滚轮横向滚动
|
||||
* - 溢出时显示左右导航按钮
|
||||
*/
|
||||
const PageTabs = defineComponent({
|
||||
name: 'PageTabs',
|
||||
props: {
|
||||
/** 标签列表 */
|
||||
tabs: { type: Array as PropType<TabView[]>, required: true },
|
||||
/** 当前激活 key */
|
||||
activeKey: { type: String, required: true },
|
||||
/** 关闭标签 */
|
||||
onClose: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||
/** 关闭当前标签左侧标签 */
|
||||
onCloseLeft: { type: Function as PropType<TabBatchHandler>, default: undefined },
|
||||
/** 关闭当前标签右侧标签 */
|
||||
onCloseRight: { type: Function as PropType<TabBatchHandler>, default: undefined },
|
||||
/** 关闭当前标签以外的标签 */
|
||||
onCloseOther: { type: Function as PropType<TabBatchHandler>, default: undefined },
|
||||
/** 关闭全部标签 */
|
||||
onCloseAll: { type: Function as PropType<TabAllHandler>, default: undefined },
|
||||
/** 切换标签 */
|
||||
onChange: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const scrollRef = ref<HTMLElement>();
|
||||
const tabItemRefs = ref<Record<string, HTMLElement | undefined>>({});
|
||||
const [showNavLeft, setShowNavLeft] = useState(false);
|
||||
const [showNavRight, setShowNavRight] = useState(false);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const updateScrollState = () => {
|
||||
const el = scrollRef.value;
|
||||
if (!el) {
|
||||
setShowNavLeft(false);
|
||||
setShowNavRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const canScroll = scrollWidth - clientWidth > 1;
|
||||
|
||||
setShowNavLeft(canScroll && scrollLeft > 1);
|
||||
setShowNavRight(canScroll && scrollLeft + clientWidth < scrollWidth - 1);
|
||||
};
|
||||
|
||||
const scrollByStep = (direction: -1 | 1) => {
|
||||
const el = scrollRef.value;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const el = scrollRef.value;
|
||||
if (!el || el.scrollWidth <= el.clientWidth) return;
|
||||
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||
if (delta === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
el.scrollLeft += delta;
|
||||
updateScrollState();
|
||||
};
|
||||
|
||||
const scrollActiveTabIntoView = () => {
|
||||
nextTick(() => {
|
||||
tabItemRefs.value[props.activeKey]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
updateScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.activeKey, scrollActiveTabIntoView);
|
||||
watch(() => props.tabs.map((t) => t.path).join(','), scrollActiveTabIntoView);
|
||||
|
||||
const setTabItemRef = (path: string, el: Element | null) => {
|
||||
if (el) {
|
||||
tabItemRefs.value[path] = el as HTMLElement;
|
||||
return;
|
||||
}
|
||||
|
||||
delete tabItemRefs.value[path];
|
||||
};
|
||||
|
||||
const handleClick = (key: string) => {
|
||||
if (key !== props.activeKey && props.onChange) {
|
||||
props.onChange(key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (key: string, e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (props.tabs.length <= 1) {
|
||||
message.warning('至少保留一个标签');
|
||||
return;
|
||||
}
|
||||
if (props.onClose) {
|
||||
props.onClose(key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchClose = (key: string) => {
|
||||
if (props.tabs.length <= 1) {
|
||||
message.warning('至少保留一个标签');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = props.tabs.findIndex((tab) => tab.path === props.activeKey);
|
||||
if (activeIndex === -1) return;
|
||||
|
||||
switch (key) {
|
||||
case 'left':
|
||||
if (activeIndex === 0) {
|
||||
message.warning('左侧没有可关闭的标签页');
|
||||
return;
|
||||
}
|
||||
props.onCloseLeft?.(props.activeKey);
|
||||
break;
|
||||
case 'right':
|
||||
if (activeIndex === props.tabs.length - 1) {
|
||||
message.warning('右侧没有可关闭的标签页');
|
||||
return;
|
||||
}
|
||||
props.onCloseRight?.(props.activeKey);
|
||||
break;
|
||||
case 'other':
|
||||
props.onCloseOther?.(props.activeKey);
|
||||
break;
|
||||
case 'all':
|
||||
props.onCloseAll?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const el = scrollRef.value;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
|
||||
resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(el);
|
||||
updateScrollState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const el = scrollRef.value;
|
||||
if (el) {
|
||||
el.removeEventListener('wheel', handleWheel);
|
||||
el.removeEventListener('scroll', updateScrollState);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
|
||||
const renderTab = (tab: TabView, i: number) => {
|
||||
const isActive = tab.path === props.activeKey;
|
||||
const showDivider = i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.path}
|
||||
ref={(el) => setTabItemRef(tab.path, el as Element | null)}
|
||||
class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')}
|
||||
onClick={() => handleClick(tab.path)}
|
||||
>
|
||||
<div v-show={showDivider} class={styles.divider} />
|
||||
|
||||
<div class={[styles.tabBg, isActive && styles.tabBgActive].filter(Boolean).join(' ')}>
|
||||
<div class={styles.tabBgInner} />
|
||||
<svg class={styles.tabBgCurveBefore} height="8" width="8">
|
||||
<path d="M 0 8 A 8 8 0 0 0 8 0 L 8 8 Z" />
|
||||
</svg>
|
||||
<svg class={styles.tabBgCurveAfter} height="8" width="8">
|
||||
<path d="M 0 0 A 8 8 0 0 0 8 8 L 0 8 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class={styles.tabContent}>
|
||||
{tab.icon ? (
|
||||
<span class={styles.tabIcon}>{renderRouteIcon(tab.icon, { size: 16 })}</span>
|
||||
) : null}
|
||||
<div class={styles.tabTitle}>{tab.title}</div>
|
||||
<div class={styles.tabClose} onClick={(e: Event) => handleClose(tab.path, e)}>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBatchMenuItem = (key: string, icon: VNode, label: string) => (
|
||||
<Menu.Item key={key}>
|
||||
<div class={styles.batchMenuItem}>
|
||||
<span class={styles.batchMenuIcon}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
return () => (
|
||||
<div class={styles.pageTabsWrap}>
|
||||
{showNavLeft.value && (
|
||||
<button
|
||||
type="button"
|
||||
class={[styles.navBtn, styles.navBtnLeft].join(' ')}
|
||||
aria-label="向左滚动标签"
|
||||
onClick={() => scrollByStep(-1)}
|
||||
>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} class={styles.pageTabs}>
|
||||
{props.tabs.map((tab, i) => renderTab(tab, i))}
|
||||
</div>
|
||||
|
||||
{showNavRight.value && (
|
||||
<button
|
||||
type="button"
|
||||
class={[styles.navBtn, styles.navBtnRight].join(' ')}
|
||||
aria-label="向右滚动标签"
|
||||
onClick={() => scrollByStep(1)}
|
||||
>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Dropdown trigger={['click']} placement="bottomRight">
|
||||
{{
|
||||
default: () => (
|
||||
<button type="button" class={styles.batchBtn} aria-label="标签页操作菜单">
|
||||
{renderAiIcon(LuMoreHorizontal)}
|
||||
</button>
|
||||
),
|
||||
overlay: () => (
|
||||
<Menu onClick={({ key }) => handleBatchClose(String(key))}>
|
||||
{renderBatchMenuItem('left', renderAiIcon(LuPanelLeftClose), '关闭左侧标签页')}
|
||||
{renderBatchMenuItem('right', renderAiIcon(LuPanelRightClose), '关闭右侧标签页')}
|
||||
{renderBatchMenuItem('other', renderAiIcon(LuCopyX), '关闭其它标签页')}
|
||||
{renderBatchMenuItem('all', renderAiIcon(LuListX), '关闭全部标签页')}
|
||||
</Menu>
|
||||
),
|
||||
}}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default PageTabs;
|
||||
@@ -1,290 +0,0 @@
|
||||
@primary: #1677ff;
|
||||
|
||||
.pageTabsWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.navBtn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: 0 0 4px rgba(214, 214, 214, 0.9);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
color 0.15s,
|
||||
background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: #ebebeb;
|
||||
}
|
||||
}
|
||||
|
||||
.navBtnLeft {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.navBtnRight {
|
||||
right: 34px;
|
||||
}
|
||||
|
||||
.batchBtn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: 0 0 4px rgba(214, 214, 214, 0.9);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: #ebebeb;
|
||||
}
|
||||
}
|
||||
|
||||
.pageTabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
&:global(:not(.dragging)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:not(.tabItemActive) {
|
||||
&:hover {
|
||||
> .divider {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
& + .tabItem {
|
||||
> .divider {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.tabClose:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&.tabItemActive {
|
||||
z-index: 2;
|
||||
|
||||
& + .tabItem {
|
||||
> .divider {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
color: @primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
.tabBg {
|
||||
&.tabBgActive {
|
||||
.tabBgInner {
|
||||
background-color: fade(@primary, 8%);
|
||||
}
|
||||
|
||||
.tabBgCurveBefore,
|
||||
.tabBgCurveAfter {
|
||||
fill: fade(@primary, 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background-color: #e8e8e8;
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tabBg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 calc(8px - 1px);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tabBgInner {
|
||||
height: 100%;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
margin 0.15s,
|
||||
border-radius 0.15s;
|
||||
}
|
||||
|
||||
.tabBgCurveBefore {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
bottom: 0;
|
||||
fill: transparent;
|
||||
transition: fill 0.15s;
|
||||
}
|
||||
|
||||
.tabBgCurveAfter {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: 0;
|
||||
fill: transparent;
|
||||
transition: fill 0.15s;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px;
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-radius: 9px;
|
||||
transition: background-color 0.35s;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tabClose {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batchMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
gap: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.batchMenuItem + .batchMenuItem {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.batchMenuIcon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 1;
|
||||
|
||||
> svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { MenuNode } from '@/types';
|
||||
|
||||
/**
|
||||
* ====== 兜底路由配置 ======
|
||||
*
|
||||
* 当后端菜单接口不可用时,使用此配置作为降级方案。
|
||||
* 数据格式与后端 `/menu/tree` 接口返回完全一致,
|
||||
* 因此可以直接通过 transformMenuToRoutes / transformMenuNode 复用同一套处理逻辑。
|
||||
*
|
||||
* ## 后端应返回的数组格式示例
|
||||
*
|
||||
* ```json
|
||||
* [
|
||||
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "AiOutlineDashboard" },
|
||||
* {
|
||||
* "id": "events", "name": "赛事管理", "path": "events", "icon": "AiOutlineTrophy",
|
||||
* "children": [
|
||||
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list", "icon": "AiOutlineUnorderedList" }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* ### 字段说明
|
||||
* | 字段 | 类型 | 必填 | 说明 |
|
||||
* |------|------|------|------|
|
||||
* | id | number\|string | 是 | 唯一标识 |
|
||||
* | name | string | 是 | 菜单名称/路由 name |
|
||||
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
||||
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
||||
* | icon | string | 否 | vue-icons-plus 图标名(如 AiOutlineTrophy,需在 routeIcon.tsx 注册) |
|
||||
* | sort | number | 否 | 排序权重(越小越前) |
|
||||
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
||||
* | externalLink | string | 否 | 外链地址 |
|
||||
* | activeMenu | string | 否 | 高亮菜单路径 |
|
||||
* | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) |
|
||||
* | permission | string | 否 | 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 |
|
||||
* | children | MenuNode[] | 否 | 子路由 |
|
||||
*
|
||||
* ### 关于 routes → menuItems 的转换
|
||||
* `menuStore.ts` 中的 `transformMenuNode()` 已完成此功能,
|
||||
* 它将 MenuNode[] 递归转换为 antd Menu 组件所需的 MenuItemRaw[] 格式。
|
||||
*/
|
||||
|
||||
export const FALLBACK_MENU_NODES: MenuNode[] = [
|
||||
{
|
||||
id: 'events',
|
||||
name: '赛事管理',
|
||||
path: 'events',
|
||||
icon: 'AiOutlineTrophy',
|
||||
children: [
|
||||
{
|
||||
id: 'events_list',
|
||||
name: '赛事列表',
|
||||
path: 'events/list',
|
||||
component: 'events/list',
|
||||
componentName: 'EventList',
|
||||
icon: 'AiOutlineUnorderedList',
|
||||
permission: 'events.list.view',
|
||||
},
|
||||
{
|
||||
id: 'events_orders',
|
||||
name: '订单管理',
|
||||
path: 'events/orders',
|
||||
component: 'events/orders',
|
||||
componentName: 'EventOrders',
|
||||
icon: 'AiOutlineShoppingCart',
|
||||
permission: 'events.orders.view',
|
||||
},
|
||||
{
|
||||
id: 'events_users',
|
||||
name: '用户列表',
|
||||
path: 'events/users',
|
||||
component: 'events/users',
|
||||
componentName: 'EventUsers',
|
||||
icon: 'AiOutlineTeam',
|
||||
permission: 'events.users.view',
|
||||
},
|
||||
{
|
||||
id: 'events_banner',
|
||||
name: 'Banner配置',
|
||||
path: 'events/banner',
|
||||
component: 'events/banner',
|
||||
componentName: 'EventBanner',
|
||||
icon: 'AiOutlinePicture',
|
||||
permission: 'events.banner.view',
|
||||
},
|
||||
{
|
||||
id: 'events_logs',
|
||||
name: '赛事操作日志',
|
||||
path: 'events/logs',
|
||||
component: 'events/logs',
|
||||
componentName: 'EventLogs',
|
||||
icon: 'AiOutlineHistory',
|
||||
permission: 'events.logs.view',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
name: '账务管理',
|
||||
path: 'finance',
|
||||
icon: 'AiOutlineTransaction',
|
||||
children: [
|
||||
{
|
||||
id: 'finance_withdraw',
|
||||
name: '提现申请',
|
||||
path: 'finance/withdraw',
|
||||
component: 'finance/withdraw',
|
||||
componentName: 'FinanceWithdraw',
|
||||
icon: 'AiOutlineMoneyCollect',
|
||||
permission: 'finance.withdraw.view',
|
||||
},
|
||||
{
|
||||
id: 'finance_wallet',
|
||||
name: '用户钱包',
|
||||
path: 'finance/wallet',
|
||||
component: 'finance/wallet',
|
||||
componentName: 'FinanceWallet',
|
||||
icon: 'AiOutlineWallet',
|
||||
permission: 'finance.wallet.view',
|
||||
},
|
||||
{
|
||||
id: 'finance_payments',
|
||||
name: '支付流水',
|
||||
path: 'finance/payments',
|
||||
component: 'finance/payments',
|
||||
componentName: 'FinancePayments',
|
||||
icon: 'AiOutlinePayCircle',
|
||||
permission: 'finance.payments.view',
|
||||
},
|
||||
{
|
||||
id: 'finance_reports',
|
||||
name: '账务报表',
|
||||
path: 'finance/reports',
|
||||
component: 'finance/reports',
|
||||
componentName: 'FinanceReports',
|
||||
icon: 'AiOutlineBarChart',
|
||||
permission: 'finance.reports.view',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: '系统管理',
|
||||
path: 'system',
|
||||
icon: 'AiOutlineSetting',
|
||||
children: [
|
||||
{
|
||||
id: 'system_users',
|
||||
name: '用户管理',
|
||||
path: 'system/users',
|
||||
component: 'system/users',
|
||||
componentName: 'SystemUsers',
|
||||
icon: 'AiOutlineUser',
|
||||
permission: 'system.users.view',
|
||||
},
|
||||
{
|
||||
id: 'system_roles',
|
||||
name: '角色权限',
|
||||
path: 'system/roles',
|
||||
component: 'system/roles',
|
||||
componentName: 'SystemRoles',
|
||||
icon: 'AiOutlineSafetyCertificate',
|
||||
permission: 'system.roles.view',
|
||||
},
|
||||
{
|
||||
id: 'system_logs',
|
||||
name: '操作日志',
|
||||
path: 'system/logs',
|
||||
component: 'system/logs',
|
||||
componentName: 'SystemLogs',
|
||||
icon: 'AiOutlineAudit',
|
||||
permission: 'system.logs.view',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 全局假数据开关
|
||||
*
|
||||
* true = 所有 API 使用假数据(模拟真实网络延时 2s 后返回假数据)
|
||||
* false = 走真实后端接口
|
||||
*
|
||||
* 对接后端接口后将此值改为 false 即可切换。
|
||||
*/
|
||||
export const USE_MOCK = false;
|
||||
|
||||
/** 模拟网络延时(毫秒) */
|
||||
export const MOCK_DELAY = 850;
|
||||
@@ -7,6 +7,4 @@ export * from './useAuth';
|
||||
export * from './useWebSocket';
|
||||
export * from './useBasicLayout';
|
||||
export * from './useBreadcrumb';
|
||||
export * from './useThrottleFn';
|
||||
export * from './useContainerSize';
|
||||
export * from './useContext';
|
||||
|
||||
+6
-74
@@ -1,102 +1,34 @@
|
||||
// src/hooks/useAuth.ts
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { useState } from './useState';
|
||||
import { useEffect } from './useEffect';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useTabsStore } from '@/stores/tabsStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { clearAuthDataCache } from '@/api/authCache';
|
||||
import router from '@/router';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||
|
||||
let isForceLoggingOut = false;
|
||||
|
||||
function createAuth() {
|
||||
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
|
||||
|
||||
useEffect(() => {
|
||||
const newToken = token.value;
|
||||
watch(token, (newToken) => {
|
||||
if (newToken) {
|
||||
window.localStorage.setItem(TOKEN_KEY, newToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}, [token]);
|
||||
});
|
||||
|
||||
const isLoggedIn = () => !!token.value;
|
||||
|
||||
const clearSession = () => {
|
||||
setToken(null);
|
||||
|
||||
const { clearUser } = useUserStore();
|
||||
const { clearMenu } = useMenuStore();
|
||||
const { clearPermissions } = usePermissionStore();
|
||||
const { clearTabs } = useTabsStore();
|
||||
|
||||
clearUser();
|
||||
clearTabs();
|
||||
clearMenu();
|
||||
clearPermissions();
|
||||
clearAuthDataCache();
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
||||
*/
|
||||
const login = async (newToken: string, targetPath?: string) => {
|
||||
isForceLoggingOut = false;
|
||||
const login = (newToken: string) => {
|
||||
setToken(newToken);
|
||||
|
||||
const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore();
|
||||
const { loadPermissions } = usePermissionStore();
|
||||
|
||||
// 并行拉取菜单和权限
|
||||
await Promise.all([loadMenu(), loadPermissions()]);
|
||||
|
||||
// 权限获取失败时会清空会话,此时不再跳转首页
|
||||
if (!isLoggedIn()) return;
|
||||
|
||||
const nextPath =
|
||||
targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404';
|
||||
|
||||
// 动态路由注册完成后,再跳转到有效目标页
|
||||
router.push(nextPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* 退出:清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页
|
||||
*/
|
||||
const logout = () => {
|
||||
clearSession();
|
||||
router.push('/login');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return {
|
||||
token,
|
||||
isLoggedIn,
|
||||
isLoggedIn: () => !!token.value,
|
||||
login,
|
||||
logout,
|
||||
clearSession,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuth();
|
||||
|
||||
/**
|
||||
* 强制重新登录:提示错误 → 清空会话 → 跳转登录页
|
||||
* 用于权限获取失败、登录态失效等需要彻底重置会话的场景
|
||||
*/
|
||||
export function forceReLogin(msg = '账户权限获取失败,请重新登录') {
|
||||
if (isForceLoggingOut) return;
|
||||
isForceLoggingOut = true;
|
||||
|
||||
notification.error({
|
||||
message: '提示',
|
||||
description: msg,
|
||||
duration: 2600,
|
||||
});
|
||||
auth.clearSession();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
+72
-19
@@ -1,46 +1,99 @@
|
||||
import { h } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
|
||||
import { routes } from '@/router/routes';
|
||||
import { generateMenu, type MenuItem } from '@/router/utils';
|
||||
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 menuItems = [
|
||||
{
|
||||
key: '/events',
|
||||
icon: h(TrophyOutlined),
|
||||
label: '我的赛事',
|
||||
},
|
||||
// { TODO 本期先做隐藏
|
||||
// key: '/wallet',
|
||||
// icon: h(WalletOutlined),
|
||||
// label: '钱包管理',
|
||||
// },
|
||||
];
|
||||
/**
|
||||
* 侧边栏菜单配置
|
||||
* 由主布局("/")的子路由生成,不再手写维护
|
||||
*/
|
||||
const layoutChildren = routes.find((r) => r.path === '/' && r.children?.length)?.children ?? [];
|
||||
const menuItems = generateMenu(layoutChildren);
|
||||
|
||||
/**
|
||||
* 收集所有叶子菜单(可点击、可高亮的具体菜单项)的 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 = () => {
|
||||
const matchedRoutes = route.matched;
|
||||
for (const r of matchedRoutes) {
|
||||
if (leafMenuKeys.has(route.path)) {
|
||||
return route.path;
|
||||
}
|
||||
for (const r of route.matched) {
|
||||
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;
|
||||
};
|
||||
|
||||
const handleMenuClick = (info: any) => {
|
||||
/**
|
||||
* 获取需要展开的子菜单 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 }) => {
|
||||
router.push(String(info.key));
|
||||
};
|
||||
|
||||
return {
|
||||
// 面包屑数据
|
||||
breadcrumbItems,
|
||||
// 菜单配置(由路由生成)
|
||||
menuItems,
|
||||
// 获取激活菜单 key
|
||||
getActiveMenuKey,
|
||||
// 获取展开菜单 keys
|
||||
getOpenKeys,
|
||||
// 菜单点击处理
|
||||
handleMenuClick,
|
||||
};
|
||||
}
|
||||
|
||||
+23
-18
@@ -1,28 +1,39 @@
|
||||
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 = (currentPath: string, recordPath: string): string => {
|
||||
const buildPath = (parentPath: string, recordPath: string): string => {
|
||||
if (recordPath.startsWith('/')) {
|
||||
return recordPath;
|
||||
}
|
||||
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
|
||||
return `${parentPath}/${recordPath}`.replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched;
|
||||
const items: Array<{ title: string; path?: string }> = [];
|
||||
const items: Array<{ title: string; path: string }> = [];
|
||||
let currentPath = '';
|
||||
|
||||
for (const record of matched) {
|
||||
for (const record of route.matched) {
|
||||
currentPath = buildPath(currentPath, record.path);
|
||||
if (record.meta && record.meta.title) {
|
||||
if (record.meta?.title) {
|
||||
items.push({
|
||||
title: record.meta.title as string,
|
||||
path: currentPath,
|
||||
@@ -38,25 +49,19 @@ export function useBreadcrumb() {
|
||||
key: index,
|
||||
title:
|
||||
item.path && index !== breadcrumbs.value.length - 1
|
||||
? h(
|
||||
? // 非末项:渲染为可点击链接
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.preventDefault();
|
||||
// 最保守方案:只有当前路径包含 /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 : {},
|
||||
});
|
||||
router.push(item.path);
|
||||
},
|
||||
},
|
||||
item.title,
|
||||
)
|
||||
: item.title,
|
||||
: // 末项:仅显示文本,表示当前所在位置
|
||||
item.title,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 监听容器尺寸变化,返回动态 width/height 给 Table 的 scroll 属性使用。
|
||||
*
|
||||
* 基于 ResizeObserver,窗口缩放 / 侧边栏收起展开 / 筛选区折叠 等任何导致
|
||||
* 容器尺寸变化的行为都会自动触发重新计算,无需额外处理。
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* const { containerRef, width, height } = useContainerSize();
|
||||
* // <div ref={containerRef}>
|
||||
* // <Table scroll={{ x: width.value, y: height.value }} />
|
||||
* // </div>
|
||||
* ```
|
||||
*
|
||||
* @param headerOffset 从容器高度中扣除的表头偏移量,默认 55
|
||||
*/
|
||||
export function useContainerSize(options?: { headerOffset?: number }) {
|
||||
const { headerOffset = 55 } = options ?? {};
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null) as Ref<HTMLElement | null>;
|
||||
const width = ref(0);
|
||||
const height = ref(0);
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
const updateSize = () => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
width.value = el.clientWidth;
|
||||
height.value = Math.max(el.clientHeight - headerOffset, 200);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSize();
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w !== width.value) width.value = w;
|
||||
if (h !== height.value) height.value = Math.max(h - headerOffset, 200);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
onUnmounted(() => observer?.disconnect());
|
||||
|
||||
return { containerRef, width, height };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/hooks/useDebounce.ts
|
||||
import { onUnmounted, Ref } from 'vue';
|
||||
import { watch, onUnmounted } from 'vue';
|
||||
import { useState } from './useState';
|
||||
import { useEffect } from './useEffect';
|
||||
|
||||
export interface UseDebounceOptions {
|
||||
delay?: number;
|
||||
@@ -9,11 +8,14 @@ export interface UseDebounceOptions {
|
||||
maxWait?: number;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(source: Ref<T>, options: number | UseDebounceOptions = {}) {
|
||||
export function useDebounce<T>(
|
||||
source: any, // Ref<T>
|
||||
options: number | UseDebounceOptions = {},
|
||||
) {
|
||||
const config = typeof options === 'number' ? { delay: options } : options;
|
||||
const { delay = 300, immediate = false, maxWait } = config;
|
||||
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(source.value as T);
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
let timer: any = null;
|
||||
@@ -41,7 +43,7 @@ export function useDebounce<T>(source: Ref<T>, options: number | UseDebounceOpti
|
||||
isFirstCall = true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
watch(source, () => {
|
||||
setIsPending(true);
|
||||
|
||||
// 处理 maxWait
|
||||
@@ -62,9 +64,7 @@ export function useDebounce<T>(source: Ref<T>, options: number | UseDebounceOpti
|
||||
updateValue();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return clearTimers;
|
||||
}, [source]);
|
||||
});
|
||||
|
||||
// 卸载
|
||||
onUnmounted(() => {
|
||||
|
||||
+19
-26
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// src/hooks/usePagination.ts
|
||||
import { computed, ref, Ref, triggerRef } from 'vue';
|
||||
import { useEffect } from './useEffect';
|
||||
import { computed, ref, watch, Ref, triggerRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 分页配置项
|
||||
@@ -78,20 +77,24 @@ export function usePagination(options: UsePaginationOptions = {}): UsePagination
|
||||
const pageSizeRef = ref(options.pageSize ?? defaultPageSize);
|
||||
const totalRef = ref(options.total ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
const val = options.current;
|
||||
if (val !== undefined) currentRef.value = val;
|
||||
}, [() => options.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const val = options.pageSize;
|
||||
if (val !== undefined) pageSizeRef.value = val;
|
||||
}, [() => options.pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const val = options.total;
|
||||
if (val !== undefined) totalRef.value = val;
|
||||
}, [() => options.total]);
|
||||
watch(
|
||||
() => options.current,
|
||||
(val) => {
|
||||
if (val !== undefined) currentRef.value = val;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => options.pageSize,
|
||||
(val) => {
|
||||
if (val !== undefined) pageSizeRef.value = val;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => options.total,
|
||||
(val) => {
|
||||
if (val !== undefined) totalRef.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const total = totalRef.value;
|
||||
@@ -180,13 +183,3 @@ export function usePagination(options: UsePaginationOptions = {}): UsePagination
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
/** 将接口返回的分页 total 同步到 pagination(支持 number / 数字字符串,含 0) */
|
||||
export function syncPaginationTotal(
|
||||
pagination: Pick<UsePaginationReturn, 'setTotal'>,
|
||||
total: unknown,
|
||||
): void {
|
||||
if (total == null || total === '') return;
|
||||
const n = Number(total);
|
||||
if (Number.isFinite(n)) pagination.setTotal(n);
|
||||
}
|
||||
|
||||
+23
-22
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { ref, Ref, WatchSource, onUnmounted, computed } from 'vue';
|
||||
import { useEffect } from './useEffect';
|
||||
import { ref, Ref, watch, WatchSource, onUnmounted, computed } from 'vue';
|
||||
|
||||
export interface UseRequestReturn<T> {
|
||||
/** 纯净的数据,不包含后端外层壳 */
|
||||
@@ -129,29 +128,31 @@ export function useRequest<T>(
|
||||
const shouldAutoRun = computed(() => !manual && ready.value);
|
||||
let hasAutoRun = false; // 标记是否已自动执行过,防止重复触发
|
||||
|
||||
useEffect(() => {
|
||||
const val = shouldAutoRun.value;
|
||||
if (val && !hasAutoRun) {
|
||||
hasAutoRun = true;
|
||||
run();
|
||||
} else if (!val) {
|
||||
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
|
||||
hasAutoRun = false;
|
||||
}
|
||||
}, [shouldAutoRun]);
|
||||
watch(
|
||||
shouldAutoRun,
|
||||
(val) => {
|
||||
if (val && !hasAutoRun) {
|
||||
hasAutoRun = true;
|
||||
run();
|
||||
} else if (!val) {
|
||||
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
|
||||
hasAutoRun = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// refreshDeps 变化时重新请求(初始化时不再重复触发)
|
||||
if (refreshDeps && refreshDeps.length > 0) {
|
||||
let skipInitialRefresh = true;
|
||||
useEffect(() => {
|
||||
if (skipInitialRefresh) {
|
||||
skipInitialRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (shouldAutoRun.value) {
|
||||
run();
|
||||
}
|
||||
}, refreshDeps);
|
||||
watch(
|
||||
refreshDeps,
|
||||
() => {
|
||||
if (shouldAutoRun.value) {
|
||||
run();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
const getSignal = () => abortController?.signal;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 节流函数封装
|
||||
* @param fn 需要节流的函数
|
||||
* @param delay 节流间隔时间(ms),默认 500
|
||||
* @returns 返回节流后的函数,连续调用时只会在每个 delay 周期内执行一次
|
||||
*/
|
||||
export function useThrottleFn<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number = 500,
|
||||
): T & { cancel: () => void } {
|
||||
const lastTime = ref<number>(0);
|
||||
let timer: any = null;
|
||||
|
||||
const throttled = function (this: any, ...args: any[]) {
|
||||
const now = Date.now();
|
||||
const remaining = delay - (now - lastTime.value);
|
||||
|
||||
if (remaining <= 0) {
|
||||
// 冷却时间已过,立即执行
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
lastTime.value = now;
|
||||
fn.apply(this, args);
|
||||
} else if (!timer) {
|
||||
// 冷却时间未过,延后执行
|
||||
timer = setTimeout(() => {
|
||||
lastTime.value = Date.now();
|
||||
timer = null;
|
||||
fn.apply(this, args);
|
||||
}, remaining);
|
||||
}
|
||||
} as T & { cancel: () => void };
|
||||
|
||||
throttled.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return throttled;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, onBeforeUnmount, toValue } from 'vue';
|
||||
import { ref, watch, onBeforeUnmount, toValue } from 'vue';
|
||||
import type { Ref, MaybeRefOrGetter } from 'vue';
|
||||
import { useEffect } from './useEffect';
|
||||
|
||||
// --- 类型定义 ---
|
||||
export type WebSocketStatus = 'connecting' | 'open' | 'closed' | 'error';
|
||||
@@ -217,16 +216,14 @@ export function useWebSocket(
|
||||
// --- 监听与副作用 ---
|
||||
|
||||
// 监听 URL 变化,如果变了则重新连接
|
||||
let skipInitialUrlWatch = true;
|
||||
useEffect(() => {
|
||||
if (skipInitialUrlWatch) {
|
||||
skipInitialUrlWatch = false;
|
||||
return;
|
||||
}
|
||||
if (status.value !== 'closed') {
|
||||
connect();
|
||||
}
|
||||
}, [() => toValue(url)]);
|
||||
watch(
|
||||
() => toValue(url),
|
||||
() => {
|
||||
if (status.value !== 'closed') {
|
||||
connect();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
+175
-116
@@ -1,141 +1,200 @@
|
||||
.basicLayoutMain {
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
color: #1a1a1a;
|
||||
.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;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
:deep(.ant-menu-item) {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
color: #595959;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
:global {
|
||||
.ant-breadcrumb-link,
|
||||
.ant-breadcrumb-separator,
|
||||
.ant-breadcrumb-item a {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.ant-breadcrumb-item:last-child {
|
||||
.ant-breadcrumb-link,
|
||||
.ant-breadcrumb-separator,
|
||||
a {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.displayName {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.logoutIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pageView {
|
||||
:deep(.ant-menu-item-selected) {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
margin: 0;
|
||||
color: #595959;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pageTabs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 4px 8px 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
:deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
|
||||
color: #1890ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown 菜单通过 portal 挂到 body,不在 .basicLayoutMain 内。
|
||||
* 若嵌套在 .basicLayoutMain 下,CSS Modules 会生成「父级 + 子级」选择器,导致匹配失败。
|
||||
*/
|
||||
.menuItem {
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
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;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: 18px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
padding: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #595959;
|
||||
border-radius: 4px;
|
||||
|
||||
:global(.anticon) {
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemDanger {
|
||||
color: #ff4d4f;
|
||||
.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;
|
||||
}
|
||||
|
||||
.pageContent {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
+118
-261
@@ -1,315 +1,172 @@
|
||||
import { computed, defineComponent, h, Transition, KeepAlive } from 'vue';
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
||||
import type { MenuProps } from 'ant-design-vue';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { Layout, Menu, Breadcrumb, Modal, Avatar, Space } from 'ant-design-vue';
|
||||
import { RouterView, useRouter } from 'vue-router';
|
||||
import {
|
||||
EditOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
BellOutlined,
|
||||
FullscreenOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useTabsStore } from '@/stores/tabsStore';
|
||||
import { useState, useBreadcrumb, auth } from '@/hooks';
|
||||
import { ChangePasswordModal, PageTabs } from '@/components';
|
||||
import { updateUserPwd } from '@/api/users';
|
||||
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||
import logoutSvg from '@/assets/img/icon/logout.svg';
|
||||
import { useBasicLayout } from '../hooks/useBasicLayout';
|
||||
import { auth } from '@/hooks/useAuth';
|
||||
import styles from './BasicLayout.module.less';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
background: '#fff',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
color: '#1a1a1a',
|
||||
boxShadow: '0 1px 4px rgba(0, 21, 41, 0.08)',
|
||||
height: 'auto',
|
||||
};
|
||||
|
||||
const siderStyle: CSSProperties = {
|
||||
background: '#fff',
|
||||
boxShadow: '2px 0 8px rgba(0, 21, 41, 0.08)',
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
padding: '0 50px 16px 50px',
|
||||
textAlign: 'center',
|
||||
color: '#3a3a3a',
|
||||
userSelect: 'none',
|
||||
};
|
||||
// 解构 Layout 组件
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
/**
|
||||
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
||||
* 递归处理 iconName → vue-icons-plus 图标 VNode
|
||||
*/
|
||||
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
||||
return items.map((item) => {
|
||||
const resolved: any = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
};
|
||||
|
||||
if (item.iconName) {
|
||||
resolved.icon = () => renderRouteIcon(item.iconName, { size: 16 });
|
||||
}
|
||||
|
||||
if (item.children?.length) {
|
||||
resolved.children = resolveMenuItems(item.children);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础布局:左侧导航(后端菜单驱动) + 顶部栏 + 内容区 + 页脚
|
||||
* 基础布局组件
|
||||
*
|
||||
* @description
|
||||
* CPMS 运营平台的主布局结构,包含:
|
||||
* - 左侧侧边栏:Logo + 导航菜单
|
||||
* - 顶部 Header:菜单折叠按钮 + 面包屑 + 用户信息
|
||||
* - 内容区域:路由视图渲染区域
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在路由中使用
|
||||
* {
|
||||
* path: '/',
|
||||
* component: BasicLayout,
|
||||
* children: [...]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BasicLayout',
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
// 使用布局 Hook 获取菜单和面包屑数据
|
||||
const { breadcrumbItems, menuItems, getActiveMenuKey, getOpenKeys, handleMenuClick } =
|
||||
useBasicLayout();
|
||||
|
||||
// 路由实例,用于退出登录后跳转到登录页
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const { menuItems } = useMenuStore();
|
||||
const { phone, nickname } = useUserStore();
|
||||
const displayName = computed(() => nickname.value || phone.value);
|
||||
const {
|
||||
tabs,
|
||||
cachedViews,
|
||||
removeTab,
|
||||
removeLeftTabs,
|
||||
removeRightTabs,
|
||||
removeOtherTabs,
|
||||
removeAllTabs,
|
||||
} = useTabsStore();
|
||||
const { breadcrumbs } = useBreadcrumb();
|
||||
|
||||
const selectedKeys = computed<string[]>(() => [route.path]);
|
||||
|
||||
const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
router.push(key as string);
|
||||
};
|
||||
/**
|
||||
* 侧边栏折叠状态
|
||||
* true: 折叠状态(只显示图标)
|
||||
* false: 展开状态(显示图标+文字)
|
||||
*/
|
||||
const collapsed = ref(false);
|
||||
|
||||
/**
|
||||
* 切换侧边栏折叠/展开状态
|
||||
*/
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed.value);
|
||||
collapsed.value = !collapsed.value;
|
||||
};
|
||||
|
||||
const activeTabKey = computed(() => route.path);
|
||||
|
||||
/** ===== 退出登录 ===== */
|
||||
/**
|
||||
* 退出登录:二次确认后跳转到登录页(不调用接口)
|
||||
* - 点击用户名弹出确认弹框
|
||||
* - 确定 -> 跳转登录页
|
||||
* - 取消 -> 关闭弹框,不做任何处理
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否退出登录?',
|
||||
okText: '确认',
|
||||
title: '确认退出',
|
||||
content: '确定要退出当前账号吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
const { clearUser } = useUserStore();
|
||||
clearUser();
|
||||
// 清除本地登录态(不调用接口),否则路由守卫会把 /login 重定向回首页
|
||||
auth.logout();
|
||||
router.replace('/login');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** ===== 修改密码弹窗 ===== */
|
||||
const [changePwdVisible, setChangePwdVisible] = useState<boolean>(false);
|
||||
const [changePwdSubmitting, setChangePwdSubmitting] = useState<boolean>(false);
|
||||
|
||||
const handleChangePassword = () => {
|
||||
setChangePwdVisible(true);
|
||||
};
|
||||
|
||||
const handleChangePwdSave = async (payload: { newPassword: string }) => {
|
||||
if (changePwdSubmitting.value) return;
|
||||
setChangePwdSubmitting(true);
|
||||
try {
|
||||
const res = await updateUserPwd({
|
||||
password: payload.newPassword,
|
||||
confirmPassword: payload.newPassword,
|
||||
});
|
||||
if (res.code === '200') {
|
||||
message.success('密码修改成功');
|
||||
setChangePwdVisible(false);
|
||||
window.setTimeout(() => {
|
||||
auth.logout();
|
||||
}, 1500);
|
||||
} else {
|
||||
message.error(res.msg || '修改失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('修改密码失败:', err);
|
||||
} finally {
|
||||
setChangePwdSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** ===== 头像下拉菜单:修改密码 / 退出登录 ===== */
|
||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key === 'changePassword') {
|
||||
handleChangePassword();
|
||||
} else if (key === 'logout') {
|
||||
handleLogout();
|
||||
}
|
||||
};
|
||||
|
||||
/** ===== PageTabs 事件 ===== */
|
||||
const handleTabChange = (key: string) => {
|
||||
if (key !== route.path) {
|
||||
router.push(key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClose = (key: string) => {
|
||||
removeTab(key);
|
||||
};
|
||||
|
||||
const handleCloseLeftTabs = (key: string) => {
|
||||
removeLeftTabs(key);
|
||||
};
|
||||
|
||||
const handleCloseRightTabs = (key: string) => {
|
||||
removeRightTabs(key);
|
||||
};
|
||||
|
||||
const handleCloseOtherTabs = (key: string) => {
|
||||
removeOtherTabs(key);
|
||||
};
|
||||
|
||||
const handleCloseAllTabs = () => {
|
||||
removeAllTabs();
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Layout class={styles.basicLayoutMain}>
|
||||
<Sider style={siderStyle} collapsed={collapsed.value} trigger={null} collapsible>
|
||||
<Layout class={styles.container}>
|
||||
{/* 左侧侧边栏 */}
|
||||
<Sider
|
||||
class={styles.sider}
|
||||
collapsed={collapsed.value}
|
||||
trigger={null}
|
||||
collapsible
|
||||
width={220}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div class={styles.logo}>
|
||||
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六羽赛事运营平台'}</span>
|
||||
<div class={styles.logoIcon} />
|
||||
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS运营平台'}</span>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys.value}
|
||||
items={resolvedItems.value}
|
||||
selectedKeys={[getActiveMenuKey()]}
|
||||
openKeys={getOpenKeys()}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout class={styles.main}>
|
||||
<Header style={headerStyle}>
|
||||
{/*
|
||||
── 顶部行:折叠按钮 + 面包屑 + 右侧账号信息 ──
|
||||
按需求把原来平铺在 Header 里的三块统一包到一个 row 容器内,
|
||||
形成「行容器 + tabs 容器」的两层结构
|
||||
*/}
|
||||
<div class={styles.headerRow}>
|
||||
{/* 右侧内容区域 */}
|
||||
<Layout>
|
||||
{/* 顶部 Header */}
|
||||
<Header class={styles.header} style={{ background: '#ffffff' }}>
|
||||
<div class={styles.headerLeft}>
|
||||
{/* 折叠/展开按钮 */}
|
||||
<span class={styles.trigger} onClick={toggleCollapsed}>
|
||||
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</span>
|
||||
|
||||
{/* 面包屑:紧跟在折叠按钮之后 */}
|
||||
<Breadcrumb separator=">" class={styles.breadcrumb}>
|
||||
{breadcrumbs.value.map((item, index) => {
|
||||
const isLast = index === breadcrumbs.value.length - 1;
|
||||
return (
|
||||
<Breadcrumb.Item key={item.path || index}>
|
||||
{isLast || !item.path ? (
|
||||
<span>{item.title}</span>
|
||||
) : (
|
||||
<a onClick={() => router.push(item.path as string)}>{item.title}</a>
|
||||
)}
|
||||
</Breadcrumb.Item>
|
||||
);
|
||||
})}
|
||||
</Breadcrumb>
|
||||
{/* 刷新按钮 */}
|
||||
{/* <span class={styles.refreshIcon}>
|
||||
<ReloadOutlined />
|
||||
</span> */}
|
||||
|
||||
{/* 右侧: 账号信息 + 退出 */}
|
||||
<div class={styles.headerRight}>
|
||||
<span class={styles.displayName}>{displayName.value}</span>
|
||||
<Dropdown trigger={['hover']} placement="bottomLeft">
|
||||
{{
|
||||
default: () => <img src={logoutSvg} class={styles.logoutIcon} />,
|
||||
overlay: () => (
|
||||
<Menu onClick={handleUserMenuClick}>
|
||||
<Menu.Item key="changePassword">
|
||||
<span class={styles.menuItem}>
|
||||
<EditOutlined />
|
||||
修改密码
|
||||
</span>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout">
|
||||
<span class={`${styles.menuItem} ${styles.menuItemDanger}`}>
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
),
|
||||
}}
|
||||
</Dropdown>
|
||||
</div>
|
||||
{/* 面包屑导航 */}
|
||||
{breadcrumbItems.value.length > 0 && (
|
||||
<Breadcrumb class={styles.breadcrumb}>
|
||||
{breadcrumbItems.value.map((item) => (
|
||||
<Breadcrumb.Item key={item.key}>{item.title}</Breadcrumb.Item>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
── Tabs 行:Chrome 风格页面标签栏 ──
|
||||
数据来自 tabsStore,点击切换路由,关闭按钮走 store.removeTab
|
||||
*/}
|
||||
<div class={styles.pageTabs}>
|
||||
<PageTabs
|
||||
tabs={tabs.value}
|
||||
activeKey={activeTabKey.value}
|
||||
onChange={handleTabChange}
|
||||
onClose={handleTabClose}
|
||||
onCloseLeft={handleCloseLeftTabs}
|
||||
onCloseRight={handleCloseRightTabs}
|
||||
onCloseOther={handleCloseOtherTabs}
|
||||
onCloseAll={handleCloseAllTabs}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/*
|
||||
── 路由出口 ──
|
||||
用 <KeepAlive :include="cachedViews"> 缓存已访问过的页面,
|
||||
include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name
|
||||
*/}
|
||||
{/* 内容区域 */}
|
||||
<Content class={styles.content}>
|
||||
<div class={styles.pageView}>
|
||||
<RouterView
|
||||
v-slots={{
|
||||
default: ({ Component, route }: any) => (
|
||||
<KeepAlive include={cachedViews.value}>
|
||||
<Transition name="page-fade-slide">
|
||||
{Component ? h(Component, { key: route.path }) : null}
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div class={styles.pageContent}>
|
||||
{/* 路由视图:根据当前路由渲染对应页面 */}
|
||||
<RouterView />
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
||||
</Layout>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{changePwdVisible.value && (
|
||||
<ChangePasswordModal
|
||||
visible={changePwdVisible.value}
|
||||
submitting={changePwdSubmitting.value}
|
||||
onClose={() => setChangePwdVisible(false)}
|
||||
onSave={handleChangePwdSave}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
},
|
||||
|
||||
+1
-4
@@ -4,15 +4,12 @@ import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import App from './App';
|
||||
import router from './router';
|
||||
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
|
||||
import './assets/styles/index.less';
|
||||
import ypComponent from '@yp-component/root';
|
||||
import '@yp-component/root/style.css';
|
||||
import './assets/iconfont/iconfont.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.use(Antd);
|
||||
app.use(ypComponent);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
.aboutMain {
|
||||
.title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.dashboardMain {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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.dashboardMain}>
|
||||
<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,130 +0,0 @@
|
||||
.bannerFormModalMain {
|
||||
.form {
|
||||
:global {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ant-form-item-explain-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uploader {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.uploadCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background-color: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadCardError {
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.uploadIcon {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.uploadHint {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.uploadTip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.previewWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
:global {
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.previewImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.uploadAgainBtn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 16px;
|
||||
background-color: #f0f5ff;
|
||||
border: 1px solid #d6e4ff;
|
||||
border-radius: 4px;
|
||||
color: #1677ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.timeItem {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
import { defineComponent, ref, reactive, computed, nextTick } from 'vue';
|
||||
import { useEffect, useState } from '@/hooks';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Cascader,
|
||||
InputNumber,
|
||||
Button,
|
||||
message,
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
import { JUMP_TYPE_OPTIONS } from '../model/useBannerModel';
|
||||
import {
|
||||
TITLE_MAX,
|
||||
PATH_MAX,
|
||||
URL_MAX,
|
||||
titleRules,
|
||||
imageUrlRules,
|
||||
typeRules,
|
||||
linkUrlRules,
|
||||
miniProgramPathRules,
|
||||
areaRules,
|
||||
startTimeRules,
|
||||
endTimeRules,
|
||||
sortRules,
|
||||
} from '../model/form';
|
||||
import { getProvinceCityCascaderOptions, cityNameToCascaderPath } from '@/utils/areaData';
|
||||
import { ImageCropper } from '@yp-component/root';
|
||||
import { FileImageOutlined } from '@ant-design/icons-vue';
|
||||
import { uploadFile, OssUploadType } from '@/utils/oss';
|
||||
import { isImageFile } from '@/utils/form';
|
||||
import { getEventList, getEventDetail, type TournamentAdminVO } from '../../list/model/services';
|
||||
import dayjs from 'dayjs';
|
||||
import styles from './BannerFormModal.module.less';
|
||||
|
||||
/** 所属区域 Cascader 选项:全国 + 省市两级 */
|
||||
const areaCascaderOptions: any[] = [
|
||||
{ value: '全国', label: '全国' },
|
||||
...getProvinceCityCascaderOptions(),
|
||||
];
|
||||
|
||||
/** 默认表单数据 */
|
||||
const getDefaultForm = () => ({
|
||||
title: '',
|
||||
imageUrl: '',
|
||||
imageStatus: 'empty' as 'empty' | 'uploading' | 'success' | 'error',
|
||||
type: '1' as '1' | '2' | '3' | '0',
|
||||
linkUrl: '',
|
||||
relatedEventId: undefined as string | undefined,
|
||||
area: '全国',
|
||||
displayTimeRange: null as [string, string] | null,
|
||||
sort: 1,
|
||||
});
|
||||
|
||||
/** 图片上传限制:最大 10MB */
|
||||
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
interface BannerFormModalProps {
|
||||
visible: boolean;
|
||||
record: any;
|
||||
submitting?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (formData: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 字符串转换为 File 对象
|
||||
*/
|
||||
function base64ToFile(dataUrl: string, fileName: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
|
||||
const bstr = atob(arr[1]);
|
||||
const u8arr = new Uint8Array(bstr.length);
|
||||
for (let i = 0; i < bstr.length; i++) {
|
||||
u8arr[i] = bstr.charCodeAt(i);
|
||||
}
|
||||
return new File([u8arr], fileName, { type: mime });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** 从 Cascader 路径中提取城市名(最后一级),用于区域筛选 */
|
||||
function extractCityName(path: string[]): string {
|
||||
if (!path || path.length === 0 || path[0] === '全国') return '';
|
||||
return path[path.length - 1];
|
||||
}
|
||||
|
||||
/** 从 Cascader 路径中提取存储用的区域字符串 */
|
||||
function extractAreaValue(path: string[]): string {
|
||||
if (!path || path.length === 0) return '全国';
|
||||
if (path[0] === '全国') return '全国';
|
||||
// 直辖市路径如 ["北京市"],普通城市如 ["广东省", "深圳市"]
|
||||
return path[path.length - 1];
|
||||
}
|
||||
|
||||
/** 根据跳转类型计算 parms 字段的值 */
|
||||
function buildParams(type: string, relatedEventId: string | undefined, linkUrl: string): string {
|
||||
if (type === '1') return relatedEventId ?? '';
|
||||
if (type === '2') return linkUrl || '';
|
||||
if (type === '3') return linkUrl || '';
|
||||
// type === '0' 无链接
|
||||
return '';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Banner 新增/编辑表单弹窗
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BannerFormModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object, default: null },
|
||||
submitting: { type: Boolean, default: false },
|
||||
onClose: { type: Function, required: true },
|
||||
onSave: { type: Function, required: true },
|
||||
},
|
||||
setup(props: BannerFormModalProps) {
|
||||
const formRef = ref<any>();
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const formData = reactive(getDefaultForm());
|
||||
|
||||
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
|
||||
const areaPath = ref<string[]>(['全国']);
|
||||
|
||||
/** 是否由用户主动切换了跳转类型 */
|
||||
const typeChanged = ref(false);
|
||||
|
||||
// ---- 裁剪弹窗状态 ----
|
||||
const [cropperVisible, setCropperVisible] = useState(false);
|
||||
const [uploadImageUrl, setUploadImageUrl] = useState('');
|
||||
|
||||
// ---- 赛事搜索状态 ----
|
||||
const eventKeyword = ref('');
|
||||
const [eventOptions, setEventOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [eventSearchLoading, setEventSearchLoading] = useState(false);
|
||||
/** 编辑时加载赛事详情的独立 loading */
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
/** 手动防抖计时器 */
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 赛事下拉选项:第一项固定为"不关联" */
|
||||
const eventSelectOptions = computed(() => {
|
||||
return [{ value: '', label: '不关联' }, ...eventOptions.value];
|
||||
});
|
||||
|
||||
// ---- 赛事搜索 API 调用 ----
|
||||
const doSearch = async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
setEventOptions([]);
|
||||
return;
|
||||
}
|
||||
setEventSearchLoading(true);
|
||||
try {
|
||||
const cityName = extractCityName(areaPath.value);
|
||||
const params: Record<string, string> = {
|
||||
page: '1',
|
||||
limit: '999',
|
||||
name: keyword.trim(),
|
||||
};
|
||||
if (cityName) {
|
||||
params.areaName = cityName;
|
||||
}
|
||||
const res = await getEventList(params as any);
|
||||
if (res.code === '200') {
|
||||
const list: TournamentAdminVO[] = res.data?.list || [];
|
||||
setEventOptions(
|
||||
list.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setEventOptions([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Banner] 搜索赛事失败:', e);
|
||||
setEventOptions([]);
|
||||
} finally {
|
||||
setEventSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 赛事搜索输入(带 350ms 手动防抖) */
|
||||
const onEventSearch = (value: string) => {
|
||||
eventKeyword.value = value;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
if (!value.trim()) {
|
||||
setEventOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
doSearch(value);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
/** 所属区域 Cascader 变化 */
|
||||
const onAreaChange = (val: any) => {
|
||||
const path: string[] = val || [];
|
||||
const newArea = extractAreaValue(path);
|
||||
areaPath.value = path;
|
||||
formData.area = newArea;
|
||||
|
||||
// 切换到非"全国"区域时清空已关联赛事
|
||||
if (newArea !== '全国') {
|
||||
formData.relatedEventId = undefined;
|
||||
formRef.value?.clearValidate('relatedEventId');
|
||||
}
|
||||
|
||||
// 区域变化时重新搜索(如果当前有关键词)
|
||||
if (eventKeyword.value.trim()) {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
doSearch(eventKeyword.value);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 根据 record 初始化表单 ----
|
||||
const initFormFromRecord = (record: any) => {
|
||||
const fresh = getDefaultForm();
|
||||
if (!record) {
|
||||
Object.assign(formData, fresh);
|
||||
areaPath.value = ['全国'];
|
||||
eventKeyword.value = '';
|
||||
setEventOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 区域:兼容旧格式 "广东省-深圳市"、新格式 "深圳市"、"全国"
|
||||
const rawArea = record.area || record.region || '';
|
||||
let cityName: string;
|
||||
if (rawArea.includes('-')) {
|
||||
cityName = rawArea.split('-').pop() || '全国';
|
||||
} else {
|
||||
cityName = rawArea || '全国';
|
||||
}
|
||||
areaPath.value = cityNameToCascaderPath(cityName);
|
||||
|
||||
// 跳转类型
|
||||
const type = String(record.type || record.jumpType || '1');
|
||||
|
||||
// 关联赛事ID(编辑时从 params/linkUrl 读取)
|
||||
let relatedEventId: string | undefined;
|
||||
if (type === '1') {
|
||||
const eventId = record.params || record.linkUrl || record.jumpUrl || '';
|
||||
if (eventId) {
|
||||
relatedEventId = eventId;
|
||||
// 异步获取赛事名称用于回显
|
||||
setEditLoading(true);
|
||||
getEventDetail(eventId)
|
||||
.then((detailRes) => {
|
||||
if (detailRes.code === '200') {
|
||||
const eventName = detailRes.data?.name || eventId;
|
||||
setEventOptions([{ value: eventId, label: eventName }]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setEventOptions([{ value: eventId, label: eventId }]);
|
||||
})
|
||||
.finally(() => {
|
||||
setEditLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventKeyword.value = '';
|
||||
if (type !== '1' || !relatedEventId) {
|
||||
setEventOptions([]);
|
||||
}
|
||||
|
||||
Object.assign(formData, {
|
||||
...fresh,
|
||||
title: record.title || '',
|
||||
imageUrl: record.img || record.imageUrl || '',
|
||||
imageStatus: record.img || record.imageUrl ? ('success' as const) : ('empty' as const),
|
||||
type,
|
||||
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
|
||||
relatedEventId,
|
||||
area: cityName,
|
||||
displayTimeRange:
|
||||
(record.showTimeBegin || record.displayStartTime) &&
|
||||
(record.showTimeEnd || record.displayEndTime)
|
||||
? ([
|
||||
record.showTimeBegin || record.displayStartTime,
|
||||
record.showTimeEnd || record.displayEndTime,
|
||||
] as [string, string])
|
||||
: null,
|
||||
sort: Number(record.sort ?? 1),
|
||||
});
|
||||
};
|
||||
|
||||
// ---- 监听 visible 变化重置表单 ----
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
typeChanged.value = false;
|
||||
initFormFromRecord(props.record);
|
||||
setCropperVisible(false);
|
||||
setUploadImageUrl('');
|
||||
nextTick().then(() => formRef.value?.clearValidate());
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
// ---- 清理防抖计时器 ----
|
||||
useEffect(() => {
|
||||
if (!props.visible && searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
// ---- 监听 type 变化,清理联动字段 ----
|
||||
useEffect(() => {
|
||||
const syncTypeFields = async () => {
|
||||
if (!typeChanged.value) {
|
||||
if (formData.type !== '1') formData.relatedEventId = undefined;
|
||||
if (formData.type !== '2' && formData.type !== '3') formData.linkUrl = '';
|
||||
typeChanged.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.type !== '1') {
|
||||
formData.relatedEventId = undefined;
|
||||
formRef.value?.clearValidate('relatedEventId');
|
||||
}
|
||||
if (formData.type !== '2' && formData.type !== '3') {
|
||||
formData.linkUrl = '';
|
||||
}
|
||||
const activeNames: string[] = [];
|
||||
if (formData.type === '1') activeNames.push('relatedEventId');
|
||||
if (formData.type === '2' || formData.type === '3') activeNames.push('linkUrl');
|
||||
formRef.value?.clearValidate(['relatedEventId', 'linkUrl']);
|
||||
if (activeNames.length) {
|
||||
await formRef.value?.validateFields(activeNames).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
syncTypeFields();
|
||||
}, [() => formData.type]);
|
||||
|
||||
/** 重新校验 Banner 图片字段,上传完成后清除或更新错误提示 */
|
||||
const revalidateImageUrl = async () => {
|
||||
await formRef.value?.validateFields(['imageUrl']).catch(() => {});
|
||||
};
|
||||
|
||||
// ---- 图片上传相关 ----
|
||||
const triggerFilePicker = () => {
|
||||
if (formData.imageStatus === 'uploading') return;
|
||||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
target.value = '';
|
||||
|
||||
if (!file) return;
|
||||
if (!isImageFile(file)) {
|
||||
message.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
if (file.size > IMAGE_MAX_SIZE) {
|
||||
message.error(`图片大小不能超过 ${IMAGE_MAX_SIZE / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadImageUrl(URL.createObjectURL(file));
|
||||
setCropperVisible(true);
|
||||
};
|
||||
|
||||
const handleCropConfirm = async (dataUrl: string) => {
|
||||
setCropperVisible(false);
|
||||
|
||||
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(formData.imageUrl);
|
||||
}
|
||||
|
||||
const localUrl = dataUrl;
|
||||
formData.imageUrl = localUrl;
|
||||
formData.imageStatus = 'uploading';
|
||||
|
||||
try {
|
||||
const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`);
|
||||
const result = await uploadFile(file, OssUploadType.EventCover);
|
||||
|
||||
URL.revokeObjectURL(localUrl);
|
||||
formData.imageUrl = result.url;
|
||||
formData.imageStatus = 'success';
|
||||
message.success('图片上传成功');
|
||||
revalidateImageUrl();
|
||||
} catch (err: any) {
|
||||
console.error('[Banner] 上传失败:', err);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
formData.imageUrl = '';
|
||||
formData.imageStatus = 'error';
|
||||
message.error(err?.message || '图片上传失败');
|
||||
revalidateImageUrl();
|
||||
}
|
||||
|
||||
if (uploadImageUrl.value) {
|
||||
URL.revokeObjectURL(uploadImageUrl.value);
|
||||
}
|
||||
setUploadImageUrl('');
|
||||
};
|
||||
|
||||
const onCropperCancel = () => {
|
||||
setCropperVisible(false);
|
||||
if (uploadImageUrl.value) {
|
||||
URL.revokeObjectURL(uploadImageUrl.value);
|
||||
}
|
||||
setUploadImageUrl('');
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(formData.imageUrl);
|
||||
}
|
||||
formData.imageUrl = '';
|
||||
formData.imageStatus = 'empty';
|
||||
revalidateImageUrl();
|
||||
};
|
||||
|
||||
// ---- 提交表单 ----
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.imageStatus === 'uploading') {
|
||||
message.warning('图片仍在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
img: formData.imageUrl || '',
|
||||
title: formData.title || '',
|
||||
type: formData.type,
|
||||
params: buildParams(formData.type, formData.relatedEventId, formData.linkUrl),
|
||||
area: formData.area,
|
||||
showTimeBegin: formData.displayTimeRange?.[0] || '',
|
||||
showTimeEnd: formData.displayTimeRange?.[1] || '',
|
||||
sort: String(formData.sort ?? 1),
|
||||
};
|
||||
props.onSave(payload);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<>
|
||||
<Modal
|
||||
title={props.record ? '编辑 Banner' : '新增 Banner'}
|
||||
visible={props.visible}
|
||||
onCancel={props.onClose}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
centered
|
||||
wrapClassName={styles.bannerFormModalMain}
|
||||
>
|
||||
{editLoading.value ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Form
|
||||
ref={formRef}
|
||||
layout="vertical"
|
||||
model={formData}
|
||||
class={styles.form}
|
||||
requiredMark
|
||||
>
|
||||
{/* ===== Banner 标题 ===== */}
|
||||
<Form.Item label="Banner标题" name="title" rules={titleRules}>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.title = val;
|
||||
}}
|
||||
placeholder="请输入标题"
|
||||
maxlength={TITLE_MAX}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* ===== 上传图片(必填,带裁剪) ===== */}
|
||||
<Form.Item
|
||||
label="上传图片"
|
||||
name="imageUrl"
|
||||
rules={imageUrlRules(
|
||||
() => formData.imageStatus,
|
||||
() => formData.imageUrl,
|
||||
)}
|
||||
>
|
||||
<div class={styles.uploader}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? (
|
||||
<div
|
||||
class={`${styles.uploadCard} ${
|
||||
formData.imageStatus === 'error' ? styles.uploadCardError : ''
|
||||
}`}
|
||||
onClick={triggerFilePicker}
|
||||
>
|
||||
<span class={styles.uploadIcon}>
|
||||
<FileImageOutlined />
|
||||
</span>
|
||||
<span class={styles.uploadHint}>点击上传</span>
|
||||
<span class={styles.uploadTip}>
|
||||
建议尺寸 1053*351 像素
|
||||
<br />
|
||||
支持 JPG/PNG,最大 10MB
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div class={styles.previewWrapper}>
|
||||
<Spin spinning={formData.imageStatus === 'uploading'} tip="上传中...">
|
||||
<img
|
||||
class={styles.previewImg}
|
||||
src={formData.imageUrl}
|
||||
alt="banner preview"
|
||||
/>
|
||||
</Spin>
|
||||
<div class={styles.uploadAgainBtn} onClick={handleRemoveImage}>
|
||||
重新上传
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* ===== 跳转类型 ===== */}
|
||||
<Form.Item label="跳转类型" name="type" rules={typeRules}>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.type = val;
|
||||
}}
|
||||
options={JUMP_TYPE_OPTIONS as any}
|
||||
placeholder="请选择"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* ===== 联动字段:赛事详情页 → 模糊搜索关联赛事 ===== */}
|
||||
{formData.type === '1' && (
|
||||
<Form.Item label="关联赛事" name="relatedEventId">
|
||||
<Select
|
||||
value={formData.relatedEventId}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.relatedEventId = val;
|
||||
}}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
allowClear={!eventSearchLoading.value}
|
||||
loading={eventSearchLoading.value}
|
||||
placeholder="请输入关键字搜索目标赛事"
|
||||
options={eventSelectOptions.value as any}
|
||||
onSearch={onEventSearch}
|
||||
onFocus={() => {
|
||||
if (formData.relatedEventId && eventOptions.value.length === 0) {
|
||||
doSearch(formData.relatedEventId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* ===== 联动字段:自定义链接 ===== */}
|
||||
{formData.type === '2' && (
|
||||
<Form.Item label="跳转地址" name="linkUrl" rules={linkUrlRules}>
|
||||
<Input
|
||||
value={formData.linkUrl}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.linkUrl = val;
|
||||
}}
|
||||
placeholder="https://xxx"
|
||||
maxlength={URL_MAX}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* ===== 联动字段:小程序页面 ===== */}
|
||||
{formData.type === '3' && (
|
||||
<Form.Item label="小程序路径" name="linkUrl" rules={miniProgramPathRules}>
|
||||
<Input
|
||||
value={formData.linkUrl}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.linkUrl = val;
|
||||
}}
|
||||
placeholder="/pages/index/index"
|
||||
maxlength={PATH_MAX}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* ===== 所属区域(省市树种选择 + 全国) ===== */}
|
||||
<Form.Item label="所属区域" name="area" rules={areaRules}>
|
||||
<Cascader
|
||||
v-model:value={areaPath.value}
|
||||
options={areaCascaderOptions}
|
||||
placeholder="请选择所属区域"
|
||||
allowClear={false}
|
||||
onChange={onAreaChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* ===== 展示时间(开始/结束) ===== */}
|
||||
<div class={styles.timeRow}>
|
||||
<Form.Item
|
||||
label="开始时间"
|
||||
name="displayStartTime"
|
||||
class={styles.timeItem}
|
||||
rules={startTimeRules(() => formData.displayTimeRange)}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
placeholder="请选择开始时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value={
|
||||
formData.displayTimeRange?.[0]
|
||||
? dayjs(formData.displayTimeRange[0])
|
||||
: undefined
|
||||
}
|
||||
onUpdate:value={(v: any) => {
|
||||
const newStart = v ? v.format('YYYY-MM-DD HH:mm:ss') : '';
|
||||
const currentEnd = formData.displayTimeRange?.[1] || '';
|
||||
if (newStart && currentEnd && dayjs(newStart).isAfter(dayjs(currentEnd))) {
|
||||
formData.displayTimeRange = [currentEnd, newStart];
|
||||
} else {
|
||||
formData.displayTimeRange = [newStart, currentEnd];
|
||||
}
|
||||
formRef.value?.clearValidate(['displayStartTime', 'displayEndTime']);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="结束时间"
|
||||
name="displayEndTime"
|
||||
class={styles.timeItem}
|
||||
rules={endTimeRules(() => formData.displayTimeRange)}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
placeholder="请选择结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value={
|
||||
formData.displayTimeRange?.[1]
|
||||
? dayjs(formData.displayTimeRange[1])
|
||||
: undefined
|
||||
}
|
||||
onUpdate:value={(v: any) => {
|
||||
const newEnd = v ? v.format('YYYY-MM-DD HH:mm:ss') : '';
|
||||
const currentStart = formData.displayTimeRange?.[0] || '';
|
||||
if (newEnd && currentStart && dayjs(newEnd).isBefore(dayjs(currentStart))) {
|
||||
formData.displayTimeRange = [newEnd, currentStart];
|
||||
} else {
|
||||
formData.displayTimeRange = [currentStart, newEnd];
|
||||
}
|
||||
formRef.value?.clearValidate(['displayStartTime', 'displayEndTime']);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* ===== 排序 ===== */}
|
||||
<Form.Item label="排序" name="sort" rules={sortRules}>
|
||||
<InputNumber
|
||||
value={formData.sort}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.sort = val;
|
||||
}}
|
||||
placeholder="数字越小越靠前"
|
||||
min={1}
|
||||
max={999}
|
||||
precision={0}
|
||||
style={{ width: '200px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* ===== 底部按钮 ===== */}
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={props.onClose}>取消</Button>
|
||||
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
||||
确认保存
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ===== 图片裁剪弹窗 ===== */}
|
||||
{cropperVisible.value && (
|
||||
<Modal
|
||||
visible={cropperVisible.value}
|
||||
title="裁剪图片"
|
||||
width={800}
|
||||
footer={null}
|
||||
onCancel={onCropperCancel}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ height: '500px' }}>
|
||||
<ImageCropper
|
||||
src={uploadImageUrl.value}
|
||||
autoCrop={true}
|
||||
autoCropArea={0.8}
|
||||
viewMode={1}
|
||||
dragMode="move"
|
||||
movable={true}
|
||||
scalable={true}
|
||||
zoomable={true}
|
||||
cropBoxMovable={true}
|
||||
cropBoxResizable={true}
|
||||
onCrop={handleCropConfirm}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Form,
|
||||
Space,
|
||||
Select,
|
||||
Tooltip,
|
||||
Pagination,
|
||||
Modal,
|
||||
Image,
|
||||
} from 'ant-design-vue';
|
||||
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import BannerFormModal from './components/BannerFormModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
|
||||
/**
|
||||
* bodyCell 渲染函数
|
||||
*/
|
||||
function renderBodyCell({
|
||||
column,
|
||||
text,
|
||||
record,
|
||||
renderJumpType,
|
||||
renderStatus,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
permissions,
|
||||
}: {
|
||||
column: any;
|
||||
text: any;
|
||||
record: any;
|
||||
renderJumpType: (type: string) => any;
|
||||
renderStatus: (status: string) => any;
|
||||
onEdit: (record: any) => void;
|
||||
onToggleStatus: (record: any) => void;
|
||||
onDelete: (record: any) => void;
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
canToggleStatus: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}) {
|
||||
// 图片列
|
||||
if (column.key === 'image') {
|
||||
return (
|
||||
<Image
|
||||
src={record.img}
|
||||
width={80}
|
||||
height={44}
|
||||
style={{ objectFit: 'cover', borderRadius: '4px' }}
|
||||
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAAoCAMAAABQp/7DAAAANlBMVEUAAADg4ODd3d3c3Nzb29va2trZ2dnY2NjX19fW1tbV1dXU1NTT09PS0tLR0dHQ0NDPz8/Pz8/BwHhiAAAAEnRSTlMAHx8/P09fb2+Pn5+vv7/P39+GGqSCAAABMklEQVRIx+2T27KDIAxFSRQQRbz//7edUwyhBWc6+7AuH4QNEiAllDbKj5M/4CAbMR3lH7+9LZxk3wpP3lxHJ8lbFm3OMpysbsPehDdHcLIKHGjs1hKc3NaRyNoTOFnujhFZZxfF0MKsA6hB6w2Ck9UPYNOGA5ysXsLGlhPWNpysOkAHTt25PsHJeA3HqCzUDoaCLQYrvG3tO+z1BjyC8GII/TVHcWyit+BS8Q5fU3hA2F/F5awWeCgT9CzHwMjJjFAXiAOfGFjTQSBXGpgl9BlQDf84sIKGAleYwQcUoYSHErFgB8EDn5iu3gKCgwVZOFooAyfLUBK7B27M4PWAlVmsFESoFwtKeYTlBHF1LuhzF2K9Q+/nJ1DQBSxcoGOrL4I3s0DPASBvfFq8t9gPAAAAAElFTkSuQmCC"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 标题列
|
||||
if (column.dataIndex === 'title') {
|
||||
const maxLen = 18;
|
||||
const raw = text || '';
|
||||
const display = raw.length > maxLen ? raw.slice(0, maxLen) + '...' : raw;
|
||||
return raw.length > maxLen ? (
|
||||
<Tooltip title={raw}>
|
||||
<span>{display}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{display}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 跳转类型列
|
||||
if (column.dataIndex === 'type') {
|
||||
return renderJumpType(text);
|
||||
}
|
||||
|
||||
// 跳转地址列
|
||||
if (column.dataIndex === 'linkUrl') {
|
||||
return <span>{text || '--'}</span>;
|
||||
}
|
||||
|
||||
// 展示时间列
|
||||
if (column.key === 'displayTime') {
|
||||
return (
|
||||
<span>
|
||||
{record.showTimeBegin} 至 {record.showTimeEnd}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 状态列
|
||||
if (column.dataIndex === 'status') {
|
||||
return renderStatus(text);
|
||||
}
|
||||
|
||||
// 操作列
|
||||
if (column.key === 'action') {
|
||||
const isEnabled = record.status === '1';
|
||||
const { canEdit, canToggleStatus, canDelete } = permissions;
|
||||
if (!canEdit && !canToggleStatus && !canDelete) return <span>--</span>;
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{canEdit && (
|
||||
<Button type="link" size="small" onClick={() => onEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{canToggleStatus && (
|
||||
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
|
||||
{isEnabled ? '禁用' : '启用'}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventBanner',
|
||||
setup() {
|
||||
const {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
columns,
|
||||
pagination,
|
||||
modalVisible,
|
||||
editingRecord,
|
||||
formSubmitting,
|
||||
renderJumpType,
|
||||
renderStatus,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleCloseModal,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
handleToggleStatus,
|
||||
} = useBannerModel();
|
||||
|
||||
const { containerRef, height } = useContainerSize();
|
||||
const { hasPermission } = usePermissionStore();
|
||||
const canAdd = hasPermission('events.banner.add');
|
||||
const operationPermissions = {
|
||||
canEdit: hasPermission('events.banner.edit'),
|
||||
canToggleStatus: hasPermission('events.banner.toggle_status'),
|
||||
canDelete: hasPermission('events.banner.delete'),
|
||||
};
|
||||
|
||||
const confirmDelete = (record: any) => {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
content: `确认要删除 "${record.title}" 吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => handleDelete(record),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmToggleStatus = (record: any) => {
|
||||
const isEnabled = record.status === '1';
|
||||
const actionText = isEnabled ? '禁用' : '启用';
|
||||
Modal.confirm({
|
||||
title: `${actionText}确认`,
|
||||
content: `确认要${actionText} "${record.title}" 吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleToggleStatus(record),
|
||||
});
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
...columns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 220,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<div class={pageStyles.containerMain}>
|
||||
<div class={pageStyles.filter}>
|
||||
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||
<Form.Item label="标题" name="title">
|
||||
<Input
|
||||
value={filterForm.title}
|
||||
placeholder="请输入"
|
||||
style={{ width: '200px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.title = val.target?.value ?? val ?? '')}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
value={filterForm.status}
|
||||
options={BANNER_STATUS_OPTIONS as any}
|
||||
style={{ width: '120px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
{canAdd && (
|
||||
<Button type="primary" onClick={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource.value}
|
||||
loading={loading.value}
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
>
|
||||
{{
|
||||
bodyCell: (args: any) =>
|
||||
renderBodyCell({
|
||||
...args,
|
||||
renderJumpType,
|
||||
renderStatus,
|
||||
onEdit: handleEdit,
|
||||
onToggleStatus: confirmToggleStatus,
|
||||
onDelete: confirmDelete,
|
||||
permissions: operationPermissions,
|
||||
}),
|
||||
}}
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.pagination}>
|
||||
<Pagination
|
||||
current={(pagination as any).current.value}
|
||||
pageSize={(pagination as any).pageSize.value}
|
||||
total={(pagination as any).total.value}
|
||||
showSizeChanger
|
||||
showTotal={(total: number) => `共 ${total} 条`}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalVisible.value && (
|
||||
<BannerFormModal
|
||||
visible={modalVisible.value}
|
||||
record={editingRecord.value}
|
||||
submitting={formSubmitting.value}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Banner 表单域控制器
|
||||
*
|
||||
* 集中管理 Banner 新增/编辑弹窗的所有表单规则校验与常量。
|
||||
* 各规则数组 / 工厂函数可直接传给 antd Form.Item 的 rules prop。
|
||||
*/
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
|
||||
// ============================================================
|
||||
// 常量
|
||||
// ============================================================
|
||||
|
||||
/** 标题最大长度 */
|
||||
export const TITLE_MAX = 50;
|
||||
/** 小程序路径最大长度 */
|
||||
export const PATH_MAX = 200;
|
||||
/** 跳转链接最大长度 */
|
||||
export const URL_MAX = 500;
|
||||
|
||||
// ============================================================
|
||||
// 表单规则
|
||||
// ============================================================
|
||||
|
||||
/** Banner 标题:必填 + 50 字符上限 */
|
||||
export const titleRules: Rule[] = [
|
||||
{ required: true, message: '请输入' },
|
||||
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
|
||||
];
|
||||
|
||||
/**
|
||||
* Banner 图片:必填 + 上传状态校验
|
||||
*
|
||||
* @param getImageStatus 获取当前上传状态的 getter
|
||||
* @param getImageUrl 获取当前图片 URL 的 getter
|
||||
*/
|
||||
export function imageUrlRules(getImageStatus: () => string, getImageUrl: () => string): Rule[] {
|
||||
return [
|
||||
{ required: true, message: '请上传 Banner 图片' },
|
||||
{
|
||||
validator: async () => {
|
||||
if (getImageStatus() === 'uploading') {
|
||||
return Promise.reject(new Error('图片仍在上传中,请稍候'));
|
||||
}
|
||||
const url = getImageUrl();
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||
return Promise.reject(new Error('请等待图片上传完成'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 跳转类型:必选 */
|
||||
export const typeRules: Rule[] = [{ required: true, message: '请选择跳转类型' }];
|
||||
|
||||
/** 跳转地址(自定义链接 scene=2):必填 + 最大长度 */
|
||||
export const linkUrlRules: Rule[] = [
|
||||
{ required: true, message: '请输入跳转地址' },
|
||||
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
|
||||
];
|
||||
|
||||
/** 小程序页面路径(scene=3):必填 + 最大长度 */
|
||||
export const miniProgramPathRules: Rule[] = [
|
||||
{ required: true, message: '请输入小程序页面路径' },
|
||||
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
|
||||
];
|
||||
|
||||
/** 所属区域:必选 */
|
||||
export const areaRules: Rule[] = [{ required: true, message: '请选择所属区域' }];
|
||||
|
||||
/** 排序:必填 + 1-999 整数 */
|
||||
export const sortRules: Rule[] = [
|
||||
{ required: true, message: '请输入排序' },
|
||||
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 展示开始时间规则:校验 displayTimeRange[0] 非空
|
||||
*
|
||||
* @param getDisplayTimeRange 获取展示时间范围的 getter
|
||||
*/
|
||||
export function startTimeRules(getDisplayTimeRange: () => [string, string] | null): Rule[] {
|
||||
return [
|
||||
{
|
||||
validator: async () => {
|
||||
const range = getDisplayTimeRange();
|
||||
if (!range?.[0]) {
|
||||
return Promise.reject(new Error('请选择开始时间'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示结束时间规则:校验 displayTimeRange[1] 非空
|
||||
*
|
||||
* @param getDisplayTimeRange 获取展示时间范围的 getter
|
||||
*/
|
||||
export function endTimeRules(getDisplayTimeRange: () => [string, string] | null): Rule[] {
|
||||
return [
|
||||
{
|
||||
validator: async () => {
|
||||
const range = getDisplayTimeRange();
|
||||
if (!range?.[1]) {
|
||||
return Promise.reject(new Error('请选择结束时间'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Banner 管理 - 接口服务
|
||||
* 该页面使用的所有接口集中管理
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
BannerListQueryParams,
|
||||
BannerSaveParams,
|
||||
BannerDelParams,
|
||||
BannerActiveParams,
|
||||
TournamentAdminBannerListVO,
|
||||
ApiResult,
|
||||
} from '@/api/banner/types';
|
||||
|
||||
export type {
|
||||
TournamentAdminBannerListVO,
|
||||
BannerListQueryParams,
|
||||
BannerSaveParams,
|
||||
BannerDelParams,
|
||||
BannerActiveParams,
|
||||
ApiResult,
|
||||
} from '@/api/banner/types';
|
||||
|
||||
// ============================================================
|
||||
// URL 常量
|
||||
// ============================================================
|
||||
const bannerList = '/admin/manager/banner/list';
|
||||
const bannerDel = '/admin/manager/banner/del';
|
||||
const bannerSave = '/admin/manager/banner/save';
|
||||
const bannerUpdate = '/admin/manager/banner/update';
|
||||
const bannerActive = '/admin/manager/banner/active';
|
||||
|
||||
// ============================================================
|
||||
// API 函数
|
||||
// ============================================================
|
||||
|
||||
/** Banner 列表 */
|
||||
export async function getBannerList(
|
||||
params: BannerListQueryParams,
|
||||
): Promise<ApiResult<TournamentAdminBannerListVO[]>> {
|
||||
return get(bannerList, params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** 删除 Banner */
|
||||
export function delBanner(id: string): Promise<ApiResult<Record<string, never>>> {
|
||||
return post(bannerDel, { id });
|
||||
}
|
||||
|
||||
/** 新增 Banner */
|
||||
export function saveBanner(params: BannerSaveParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post(bannerSave, params);
|
||||
}
|
||||
|
||||
/** 编辑 Banner */
|
||||
export function updateBanner(params: BannerSaveParams): Promise<ApiResult<Record<string, never>>> {
|
||||
return post(bannerUpdate, params);
|
||||
}
|
||||
|
||||
/** 启用/禁用 Banner */
|
||||
export function toggleBannerActive(
|
||||
params: BannerActiveParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post(bannerActive, params);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { StatusTag, type StatusTagTone } from '@/components';
|
||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import {
|
||||
getBannerList,
|
||||
delBanner,
|
||||
saveBanner,
|
||||
updateBanner,
|
||||
toggleBannerActive,
|
||||
type TournamentAdminBannerListVO,
|
||||
type BannerListQueryParams,
|
||||
} from './services';
|
||||
|
||||
// ============================================================
|
||||
// 常量
|
||||
// ============================================================
|
||||
|
||||
/** Banner 状态选项(value 对应 API status: 不传=全部、0=禁用;1=启用) */
|
||||
export const BANNER_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '启用' },
|
||||
{ value: '0', label: '禁用' },
|
||||
] as const;
|
||||
|
||||
/** 跳转类型选项(value 对应 API type: 0=无链接,1=赛事详情,2=自定义连接,3=小程序页面) */
|
||||
export const JUMP_TYPE_OPTIONS = [
|
||||
{ value: '1', label: '赛事详情页' },
|
||||
{ value: '2', label: '自定义链接' },
|
||||
{ value: '3', label: '小程序页面' },
|
||||
{ value: '0', label: '无链接' },
|
||||
] as const;
|
||||
|
||||
/** 跳转类型映射 */
|
||||
const JUMP_TYPE_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||
'0': { label: '无链接', tone: 'default' },
|
||||
'1': { label: '赛事详情页', tone: 'primary' },
|
||||
'2': { label: '自定义链接', tone: 'purple' },
|
||||
'3': { label: '小程序页面', tone: 'cyan' },
|
||||
};
|
||||
|
||||
/** Banner 状态映射 */
|
||||
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone; dimmed?: boolean }> = {
|
||||
'1': { label: '启用中', tone: 'success' },
|
||||
'0': { label: '已禁用', tone: 'default', dimmed: true },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Model
|
||||
// ============================================================
|
||||
|
||||
export function useBannerModel() {
|
||||
// ===== 筛选条件(key 名对齐 API 查询参数) =====
|
||||
const filterForm = reactive({
|
||||
title: '',
|
||||
status: '' as '' | '0' | '1',
|
||||
});
|
||||
|
||||
const { debouncedValue: debouncedTitle } = useDebounce(
|
||||
toRef(filterForm, 'title') as Ref<string>,
|
||||
{ delay: 300 },
|
||||
);
|
||||
|
||||
// ===== 构建 API 查询参数 =====
|
||||
const buildQueryParams = (): BannerListQueryParams => {
|
||||
const params: BannerListQueryParams = {};
|
||||
if (filterForm.title.trim()) {
|
||||
params.title = filterForm.title.trim();
|
||||
}
|
||||
if (filterForm.status) {
|
||||
params.status = filterForm.status;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// ===== 数据请求(API 返回全量,前端分页) =====
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
run: fetchList,
|
||||
} = useRequest<TournamentAdminBannerListVO[]>(() => getBannerList(buildQueryParams()), {
|
||||
formatResult: (res) => (res.code === '200' ? res.data : []),
|
||||
});
|
||||
|
||||
const allData = computed(() => data.value || []);
|
||||
|
||||
// ===== 前端分页 =====
|
||||
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
|
||||
/** 当前页数据(客户端切片) */
|
||||
const dataSource = computed(() => {
|
||||
const start = ((pagination as any).current.value - 1) * (pagination as any).pageSize.value;
|
||||
return allData.value.slice(start, start + (pagination as any).pageSize.value);
|
||||
});
|
||||
|
||||
// 同步前端分页 total
|
||||
useEffect(() => {
|
||||
pagination.setTotal(allData.value.length);
|
||||
}, [allData]);
|
||||
|
||||
// ===== 弹窗状态 =====
|
||||
const [modalVisible, setModalVisible] = useState<boolean>(false);
|
||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||
const [formSubmitting, setFormSubmitting] = useState<boolean>(false);
|
||||
|
||||
// ===== 表格列配置(dataIndex 对齐 TournamentAdminBannerListVO) =====
|
||||
const columns = [
|
||||
{ title: '图片', key: 'image', width: 100, align: 'center' as const },
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', width: 220 },
|
||||
{
|
||||
title: '跳转类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120,
|
||||
},
|
||||
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 140 },
|
||||
{ title: '展示时间', key: 'displayTime', width: 260 },
|
||||
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 160 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
|
||||
];
|
||||
|
||||
// ===== 计算属性 =====
|
||||
const hasFilter = computed(() => {
|
||||
return debouncedTitle.value.trim() !== '' || filterForm.status !== '';
|
||||
});
|
||||
|
||||
const isEdit = computed(() => editingRecord.value !== null);
|
||||
|
||||
// ===== 方法 =====
|
||||
|
||||
/** 查询 */
|
||||
const handleSearch = () => {
|
||||
pagination.reset();
|
||||
fetchList();
|
||||
};
|
||||
|
||||
/** 重置 */
|
||||
const handleReset = useThrottleFn(async () => {
|
||||
filterForm.title = '';
|
||||
filterForm.status = '';
|
||||
pagination.reset();
|
||||
await fetchList();
|
||||
}, 500);
|
||||
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.setCurrent(page);
|
||||
if (pageSize !== (pagination as any).pageSize.value) {
|
||||
pagination.setPageSize(pageSize);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开新增弹窗 */
|
||||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingRecord(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
setEditingRecord(null);
|
||||
};
|
||||
|
||||
/** 提交表单(新增/编辑) */
|
||||
const handleSave = useThrottleFn(async (formPayload: any) => {
|
||||
if (formSubmitting.value) return;
|
||||
setFormSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
img: formPayload.img || formPayload.imageUrl || '',
|
||||
title: formPayload.title || '',
|
||||
type: String(formPayload.type || formPayload.jumpType || '1'),
|
||||
params:
|
||||
formPayload.params ??
|
||||
formPayload.parms ??
|
||||
formPayload.linkUrl ??
|
||||
formPayload.jumpUrl ??
|
||||
'',
|
||||
area: formPayload.area || formPayload.region || '',
|
||||
showTimeBegin:
|
||||
formPayload.showTimeBegin ||
|
||||
formPayload.displayTimeRange?.[0] ||
|
||||
formPayload.displayStartTime ||
|
||||
'',
|
||||
showTimeEnd:
|
||||
formPayload.showTimeEnd ||
|
||||
formPayload.displayTimeRange?.[1] ||
|
||||
formPayload.displayEndTime ||
|
||||
'',
|
||||
sort: String(formPayload.sort ?? 1),
|
||||
};
|
||||
const isEdit = editingRecord.value !== null;
|
||||
const res = isEdit
|
||||
? await updateBanner({ ...payload, id: editingRecord.value.id })
|
||||
: await saveBanner(payload);
|
||||
if (res.code === '200') {
|
||||
message.success(isEdit ? '编辑成功' : '新增成功');
|
||||
handleCloseModal();
|
||||
handleSearch();
|
||||
} else {
|
||||
message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Banner提交失败:', e);
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
/** 删除 */
|
||||
const handleDelete = useThrottleFn(async (record: any) => {
|
||||
try {
|
||||
const res = await delBanner(record.id);
|
||||
if (res.code === '200') {
|
||||
message.success('删除成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '删除失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Banner删除失败:', e);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
/** 启用/禁用切换 */
|
||||
const handleToggleStatus = useThrottleFn(async (record: any) => {
|
||||
try {
|
||||
const newStatus = record.status === '1' ? '0' : '1';
|
||||
const actionText = newStatus === '1' ? '启用' : '禁用';
|
||||
const res = await toggleBannerActive({ id: record.id, status: newStatus });
|
||||
if (res.code === '200') {
|
||||
message.success(`${actionText}成功`);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Banner状态切换失败:', e);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// ===== 渲染工具 =====
|
||||
const renderJumpType = (type: string) => {
|
||||
const info = JUMP_TYPE_MAP[type] || { label: type || '--', tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||
};
|
||||
|
||||
const renderStatus = (status: string) => {
|
||||
const info = STATUS_MAP[status] || { label: status || '--', tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone, dimmed: info.dimmed });
|
||||
};
|
||||
|
||||
return {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
columns,
|
||||
pagination,
|
||||
hasFilter,
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
editingRecord,
|
||||
isEdit,
|
||||
formSubmitting,
|
||||
renderJumpType,
|
||||
renderStatus,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleCloseModal,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
handleToggleStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.eventAuditModalMain {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.desc {
|
||||
:global(.ant-descriptions-item-label) {
|
||||
width: 110px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.auditSection {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.auditHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auditTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.auditForm {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.auditField {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.ant-form-item-label) {
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { defineComponent, reactive, ref } from 'vue';
|
||||
import { Modal, Descriptions, Image, Radio, Input, Form, Spin, message } from 'ant-design-vue';
|
||||
import { useEffect, useState } from '@/hooks';
|
||||
import { getEventDetail, type TournamentAdminInfoVO } from '../model/services';
|
||||
import { validateEventAuditSubmit } from '../model/form';
|
||||
import styles from './EventAuditModal.module.less';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface EventAuditModalProps {
|
||||
visible: boolean;
|
||||
record: any;
|
||||
onClose: () => void;
|
||||
onSubmit?: (payload: { approved: boolean; comment: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事审核弹窗
|
||||
* 包含:基础信息 + 封面 + 审核操作(通过/未通过 + 审核内容)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'EventAuditModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) },
|
||||
onClose: { type: Function, required: true },
|
||||
onSubmit: { type: Function, default: undefined },
|
||||
},
|
||||
setup(props: EventAuditModalProps) {
|
||||
const detail = ref<TournamentAdminInfoVO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 审核表单
|
||||
const auditForm = reactive({
|
||||
approved: true,
|
||||
comment: '',
|
||||
});
|
||||
|
||||
/** 加载赛事详情 */
|
||||
const fetchDetail = async (id: string) => {
|
||||
setLoading(true);
|
||||
detail.value = null;
|
||||
try {
|
||||
const res = await getEventDetail(id);
|
||||
if (res.code === '200') {
|
||||
detail.value = res.data;
|
||||
} else {
|
||||
message.error(res.msg || '获取赛事详情失败');
|
||||
}
|
||||
} catch {
|
||||
// 网络层已统一提示
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 弹窗打开时重新拉取详情 */
|
||||
useEffect(() => {
|
||||
if (props.visible && props.record?.id) {
|
||||
auditForm.approved = true;
|
||||
auditForm.comment = '';
|
||||
fetchDetail(props.record.id);
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
const handleOk = () => {
|
||||
const check = validateEventAuditSubmit({
|
||||
approved: auditForm.approved,
|
||||
comment: auditForm.comment,
|
||||
});
|
||||
if (!check.valid) {
|
||||
message.warning(check.error!);
|
||||
return;
|
||||
}
|
||||
if (props.onSubmit) {
|
||||
props.onSubmit({ approved: auditForm.approved, comment: auditForm.comment });
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化时间范围 */
|
||||
const formatRange = (start: string, end: string) => {
|
||||
if (!start && !end) return '--';
|
||||
return `${start || '?'} ~ ${end || '?'}`;
|
||||
};
|
||||
|
||||
/** 渲染 0/1 开关文本 */
|
||||
const boolText = (val: string, trueText: string, falseText: string) => {
|
||||
return val === '1' ? trueText : falseText;
|
||||
};
|
||||
|
||||
/** 渲染详情内容 */
|
||||
const renderContent = () => {
|
||||
const d = detail.value!;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== 基础信息 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>基础信息</div>
|
||||
<Descriptions size="small" bordered column={3} class={styles.desc}>
|
||||
<Descriptions.Item label="赛事名称">{d.name || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="赛事时间" span={2}>
|
||||
{formatRange(d.startTimeBegin, d.startTimeEnd)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="赛事说明" span={3}>
|
||||
{d.descr || '--'}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="报名起止时间">
|
||||
{formatRange(d.signupTimeBegin, d.signupTimeEnd)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="取消报名截止时间" span={2}>
|
||||
{d.signupTimeEnd || '--'}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="省市区">{d.venueArea || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="地点">{d.venue || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="场地编号">{d.venueNo || '--'}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="公开活动">
|
||||
{boolText(d.isPublic, '在首页展示赛事', '非公开')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="证件号">
|
||||
{boolText(d.isRequireIdCardNo, '需要提供', '无需提供')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="证件图片">
|
||||
{boolText(d.isRequireIdCardImg, '需要提供', '无需提供')}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="创建时间">{d.createTime || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{d.createNickname || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">待审核</Descriptions.Item>
|
||||
<Descriptions.Item label="报名人数">{d.signupCount || '0'}</Descriptions.Item>
|
||||
<Descriptions.Item label="取消报名人数" span={1}>
|
||||
{d.dropoutCount || '0'}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="最大人数">{d.totalLimit || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="对岸数量">{d.matchTotalCount || '0'}</Descriptions.Item>
|
||||
<Descriptions.Item label="已完成对岸数量">{d.matchCount || '0'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
{/* ===== 封面 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>封面</div>
|
||||
{d.img ? <Image src={d.img} /> : <span style={{ color: '#999' }}>暂无封面</span>}
|
||||
</div>
|
||||
|
||||
{/* ===== 审核操作 ===== */}
|
||||
<div class={styles.auditSection}>
|
||||
<div class={styles.auditHeader}>
|
||||
<span class={styles.auditTitle}>审核操作</span>
|
||||
</div>
|
||||
<Form layout="horizontal" class={styles.auditForm}>
|
||||
<Form.Item label="审核状态" class={styles.auditField}>
|
||||
<Radio.Group v-model={auditForm.approved}>
|
||||
<Radio value={true}>通过</Radio>
|
||||
<Radio value={false}>未通过</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="审核内容" class={styles.auditField}>
|
||||
<TextArea
|
||||
v-model={auditForm.comment}
|
||||
placeholder="请输入审核内容"
|
||||
rows={4}
|
||||
maxlength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
title="审核"
|
||||
width={900}
|
||||
onCancel={props.onClose}
|
||||
onOk={handleOk}
|
||||
okText="提交审核"
|
||||
cancelText="取消"
|
||||
centered
|
||||
destroyOnClose
|
||||
wrapClassName={styles.eventAuditModalMain}
|
||||
>
|
||||
{loading.value ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : detail.value ? (
|
||||
renderContent()
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
.eventDetailModalMain {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.textBlock {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
white-space: pre-wrap;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.desc {
|
||||
:global(.ant-descriptions-item-label) {
|
||||
width: 110px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
// Item 的 class 会同时挂在 label/content 两个同级 td/th 上,不能用后代选择器
|
||||
.statCell:global(.ant-descriptions-item-content) {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.statCellNoWrap:global(.ant-descriptions-item-label),
|
||||
.statCellNoWrap:global(.ant-descriptions-item-content) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.divisionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.divisionItem {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import { defineComponent, computed, reactive, ref } from 'vue';
|
||||
import { Modal, Descriptions, Image, Table, Pagination, Spin, Space } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
getEventDetail,
|
||||
getSignupList,
|
||||
type TournamentAdminInfoVO,
|
||||
type InnerCategoryVO,
|
||||
type TournamentAdminInfoSignupVO,
|
||||
} from '../model/services';
|
||||
import { useState, useEffect } from '@/hooks';
|
||||
import defaultCover from '@/assets/img/default.png';
|
||||
import styles from './EventDetailModal.module.less';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
visible: boolean;
|
||||
record: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ===== 0/1 显示映射 =====
|
||||
const YES_NO_MAP: Record<string, string> = { '0': '否', '1': '是' };
|
||||
const PUBLIC_MAP: Record<string, string> = { '0': '非公开', '1': '公开' };
|
||||
const GENDER_MAP: Record<string, string> = { '1': '男', '2': '女' };
|
||||
const CATEGORY_TYPE_MAP: Record<string, string> = { '1': '单打', '2': '双打' };
|
||||
|
||||
const formatYN = (v: string) => YES_NO_MAP[v] ?? v;
|
||||
const formatPublic = (v: string) => PUBLIC_MAP[v] ?? v;
|
||||
const formatGender = (v: string) => GENDER_MAP[v] ?? v;
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
/** 渲染身份证图片(缩略图,点击可预览) */
|
||||
const renderIdImages = (imgList: string[], singleImg: string) => {
|
||||
const urls = imgList.length > 0 ? imgList : singleImg ? [singleImg] : [];
|
||||
if (urls.length === 0) return <span>--</span>;
|
||||
return (
|
||||
<Space size={4}>
|
||||
{urls.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ objectFit: 'cover', borderRadius: '4px', cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
/** 选手表格基础列(不含证件号/证件图片,由 setup 内 computed 按需拼接) */
|
||||
const BASE_PLAYER_COLUMNS: any[] = [
|
||||
{ title: '选手', dataIndex: 'realName', key: 'realName', width: 100 },
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender',
|
||||
width: 60,
|
||||
customRender: ({ text }: { text: string }) => formatGender(text),
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
];
|
||||
|
||||
const ID_CARD_COLUMN: any = {
|
||||
title: '证件号',
|
||||
dataIndex: 'idCard',
|
||||
key: 'idCard',
|
||||
width: 180,
|
||||
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
|
||||
record?.idCard ? record.idCard : <span>--</span>,
|
||||
};
|
||||
|
||||
const ID_CARD_IMG_COLUMN: any = {
|
||||
title: '证件图片',
|
||||
key: 'idCardImgs',
|
||||
width: 120,
|
||||
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
|
||||
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>--</span>,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventDetailModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) },
|
||||
onClose: { type: Function, required: true },
|
||||
},
|
||||
setup(props: EventDetailModalProps) {
|
||||
// ===== 详情数据 =====
|
||||
const [detail, setDetail] = useState<TournamentAdminInfoVO | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
/** 动态列:根据赛事是否要求证件号/证件图片决定显示 */
|
||||
const playerColumns = computed(() => {
|
||||
const cols = [...BASE_PLAYER_COLUMNS];
|
||||
if (detail.value?.isRequireIdCardNo === '1') cols.push(ID_CARD_COLUMN);
|
||||
if (detail.value?.isRequireIdCardImg === '1') cols.push(ID_CARD_IMG_COLUMN);
|
||||
return cols;
|
||||
});
|
||||
|
||||
// ===== 每个组别的选手数据(key = categoryId) =====
|
||||
const categoryPlayers = ref<Record<string, TournamentAdminInfoSignupVO[]>>({});
|
||||
const categoryPlayerTotal = ref<Record<string, number>>({});
|
||||
const categoryPlayerLoading = ref<Record<string, boolean>>({});
|
||||
const categoryPlayerPage = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取某个组别的选手列表(分页) */
|
||||
const fetchPlayers = async (categoryId: string, page: number) => {
|
||||
categoryPlayerLoading.value[categoryId] = true;
|
||||
// 触发响应式更新
|
||||
categoryPlayerLoading.value = { ...categoryPlayerLoading.value };
|
||||
try {
|
||||
const res = await getSignupList({
|
||||
categoryId,
|
||||
page: String(page),
|
||||
limit: String(PAGE_SIZE),
|
||||
});
|
||||
if (res.code === '200') {
|
||||
categoryPlayers.value[categoryId] = res.data.list;
|
||||
categoryPlayers.value = { ...categoryPlayers.value };
|
||||
categoryPlayerTotal.value[categoryId] = res.data.total;
|
||||
categoryPlayerTotal.value = { ...categoryPlayerTotal.value };
|
||||
categoryPlayerPage.value[categoryId] = page;
|
||||
categoryPlayerPage.value = { ...categoryPlayerPage.value };
|
||||
}
|
||||
} catch {
|
||||
// mock 模式不会到这里,真实模式下由 API 层处理
|
||||
} finally {
|
||||
categoryPlayerLoading.value[categoryId] = false;
|
||||
categoryPlayerLoading.value = { ...categoryPlayerLoading.value };
|
||||
}
|
||||
};
|
||||
|
||||
/** 选手分页变更 */
|
||||
const handlePlayerPageChange = (categoryId: string, page: number) => {
|
||||
fetchPlayers(categoryId, page);
|
||||
};
|
||||
|
||||
/** 获取赛事详情 */
|
||||
const fetchDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const res = await getEventDetail(id);
|
||||
if (res.code === '200' && res.data) {
|
||||
setDetail(res.data);
|
||||
// 为每个组别加载第一页选手
|
||||
for (const cat of res.data.categoryList) {
|
||||
categoryPlayers.value[cat.id] = [];
|
||||
categoryPlayerTotal.value[cat.id] = 0;
|
||||
categoryPlayerPage.value[cat.id] = 1;
|
||||
fetchPlayers(cat.id, 1);
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '获取详情失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('获取赛事详情失败:', e);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 显示报名起止时间 */
|
||||
const signupTimeDisplay = (startTime?: string, endTime?: string) => {
|
||||
if (!startTime || !endTime) return '--';
|
||||
return `${startTime} 至 ${endTime}`;
|
||||
};
|
||||
|
||||
// 弹窗打开且 有 record.id 时自动请求(useEffect 带 immediate: true,兼容 destroyOnClose 重建场景)
|
||||
useEffect(() => {
|
||||
if (props.visible && props.record?.id) {
|
||||
fetchDetail(String(props.record.id));
|
||||
}
|
||||
}, [() => props.visible, () => props.record?.id]);
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
title="查看详情"
|
||||
width={900}
|
||||
onCancel={props.onClose}
|
||||
footer={null}
|
||||
centered
|
||||
destroyOnClose
|
||||
wrapClassName={styles.eventDetailModalMain}
|
||||
>
|
||||
{detailLoading.value ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : detail.value ? (
|
||||
<>
|
||||
{/* ===== 基础信息 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>基础信息</div>
|
||||
<Descriptions size="small" bordered column={3} class={styles.desc}>
|
||||
<Descriptions.Item label="赛事名称">{detail.value.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="赛事时间" span={2}>
|
||||
{detail.value.startTimeBegin} 至 {detail.value.startTimeEnd}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="赛事说明" span={3}>
|
||||
{detail.value.descr}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="报名起止时间" span={3}>
|
||||
{signupTimeDisplay(detail.value.signupTimeBegin, detail.value.signupTimeEnd)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="省市区">{detail.value.venueArea}</Descriptions.Item>
|
||||
<Descriptions.Item label="地点">{detail.value.venue}</Descriptions.Item>
|
||||
<Descriptions.Item label="场地编号" class={styles.statCell}>
|
||||
{detail.value.venueNo || '--'}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="公开活动">
|
||||
{formatPublic(detail.value.isPublic)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="证件号">
|
||||
{formatYN(detail.value.isRequireIdCardNo)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="证件图片" class={styles.statCell}>
|
||||
{formatYN(detail.value.isRequireIdCardImg)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="创建时间">{detail.value.createTime}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人" span={2}>
|
||||
{detail.value.createNickname}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="报名人数">{detail.value.signupCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="退赛人数">{detail.value.dropoutCount}</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label="最大人数"
|
||||
class={[styles.statCell, styles.statCellNoWrap]}
|
||||
>
|
||||
{detail.value.totalLimit || '--'}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="总对阵数">
|
||||
{detail.value.matchTotalCount}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已完成对阵数" span={2}>
|
||||
{detail.value.matchCount}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
{/* ===== 封面 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>封面</div>
|
||||
<Image
|
||||
style={{ maxHeight: '380px' }}
|
||||
src={detail.value.img || defaultCover}
|
||||
fallback={defaultCover}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ===== 赛事公告 ===== */}
|
||||
{detail.value.notice && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>赛事公告</div>
|
||||
<div class={styles.textBlock}>{detail.value.notice}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 组别信息 ===== */}
|
||||
{detail.value.categoryList && detail.value.categoryList.length > 0 && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>组别信息</div>
|
||||
{detail.value.categoryList.map((cat: InnerCategoryVO, idx: number) => (
|
||||
<div key={cat.id} class={styles.group}>
|
||||
<div class={styles.groupTitle}>
|
||||
组别{idx + 1}:{cat.name}({CATEGORY_TYPE_MAP[cat.type] ?? cat.type})
|
||||
</div>
|
||||
<Table
|
||||
columns={playerColumns.value}
|
||||
dataSource={categoryPlayers.value[cat.id] || []}
|
||||
loading={categoryPlayerLoading.value[cat.id]}
|
||||
size="small"
|
||||
pagination={false}
|
||||
bordered
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
{categoryPlayerTotal.value[cat.id] > 0 && (
|
||||
<div class={styles.paginationRow}>
|
||||
<Pagination
|
||||
current={categoryPlayerPage.value[cat.id] || 1}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={categoryPlayerTotal.value[cat.id] || 0}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
onChange={(page: number) => handlePlayerPageChange(cat.id, page)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 分组信息 ===== */}
|
||||
{detail.value.groupInfoList && detail.value.groupInfoList.length > 0 && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>分组信息</div>
|
||||
<div class={styles.divisionList}>
|
||||
{detail.value.groupInfoList.map((d: string) => (
|
||||
<div key={d} class={styles.divisionItem}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
.eventRegulationModalMain {
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 10px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.textBlock {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
white-space: pre-wrap;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.imageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.imageCell {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Modal, Image, Spin } from 'ant-design-vue';
|
||||
import { getRuleInfo } from '../model/services';
|
||||
import type { TournamentAdminRuleInfoVO } from '@/api/events/types';
|
||||
import { useEffect } from '@/hooks';
|
||||
import styles from './EventRegulationModal.module.less';
|
||||
|
||||
interface EventRegulationModalProps {
|
||||
visible: boolean;
|
||||
/** 当前赛事记录(需包含 id 字段) */
|
||||
record?: Record<string, any>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventRegulationModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) },
|
||||
onClose: { type: Function, required: true },
|
||||
},
|
||||
setup(props: EventRegulationModalProps) {
|
||||
const loading = ref(false);
|
||||
const ruleData = ref<TournamentAdminRuleInfoVO | null>(null);
|
||||
|
||||
/** 调用 getRuleInfo 接口获取规程数据 */
|
||||
const fetchRuleInfo = async (id: string) => {
|
||||
loading.value = true;
|
||||
ruleData.value = null;
|
||||
try {
|
||||
const res = await getRuleInfo(id);
|
||||
if (res.code === '200' && res.data) {
|
||||
ruleData.value = res.data;
|
||||
} else {
|
||||
ruleData.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取赛事规程失我败:', e);
|
||||
ruleData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗打开且 有 record.id 时自动请求(useEffect 带 immediate: true,兼容 destroyOnClose 重建场景)
|
||||
useEffect(() => {
|
||||
if (props.visible && props.record?.id) {
|
||||
fetchRuleInfo(props.record.id);
|
||||
}
|
||||
}, [() => props.visible, () => props.record?.id]);
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
title="赛事规程"
|
||||
width={880}
|
||||
onCancel={props.onClose}
|
||||
footer={null}
|
||||
centered
|
||||
destroyOnClose
|
||||
wrapClassName={styles.eventRegulationModalMain}
|
||||
>
|
||||
<Spin spinning={loading.value} size="large">
|
||||
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
|
||||
{ruleData.value ? (
|
||||
<>
|
||||
{/* 规程内容 */}
|
||||
{ruleData.value.rulesDescr && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程内容</div>
|
||||
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 规程图片 */}
|
||||
{(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程图片</div>
|
||||
<Image.PreviewGroup>
|
||||
<div class={styles.imageGrid}>
|
||||
{ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => (
|
||||
<div key={idx} class={styles.imageCell}>
|
||||
<Image src={src} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && (
|
||||
<div
|
||||
style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}
|
||||
>
|
||||
暂无规程信息
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading.value && (
|
||||
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
|
||||
暂无规程信息
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,328 +0,0 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Form,
|
||||
Space,
|
||||
Select,
|
||||
DatePicker,
|
||||
Cascader,
|
||||
Tooltip,
|
||||
Pagination,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
useEventListModel,
|
||||
EVENT_STATUS_OPTIONS,
|
||||
PUBLIC_EVENT_OPTIONS,
|
||||
ID_CARD_OPTIONS,
|
||||
ID_CARD_IMAGE_OPTIONS,
|
||||
} from './model/useEventListModel';
|
||||
import { useEventListColumns } from './model/useEventListColumns';
|
||||
import { getProvinceCityCascaderOptions } from '@/utils/areaData';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import EventRegulationModal from './components/EventRegulationModal';
|
||||
import EventDetailModal from './components/EventDetailModal';
|
||||
import EventAuditModal from './components/EventAuditModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
/**
|
||||
* 赛事列表页面(纯视图层)
|
||||
*
|
||||
* 架构分层:
|
||||
* - 数据层:services.ts(API 调用)
|
||||
* - 逻辑层:useEventListModel.ts(状态 + 业务逻辑 + 交互处理)
|
||||
* - 视图层:useEventListColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'EventList',
|
||||
setup() {
|
||||
const {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
regulationModalVisible,
|
||||
detailModalVisible,
|
||||
auditModalVisible,
|
||||
currentRecord,
|
||||
togglingId,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
openDetailModal,
|
||||
openRegulationModal,
|
||||
closeRegulationModal,
|
||||
closeDetailModal,
|
||||
closeAuditModal,
|
||||
submitAudit,
|
||||
handleToggleShelf,
|
||||
} = useEventListModel();
|
||||
|
||||
const { columns } = useEventListColumns();
|
||||
const { containerRef, height } = useContainerSize();
|
||||
const { hasPermission } = usePermissionStore();
|
||||
const canToggleStatus = hasPermission('events.list.toggle_status');
|
||||
const canViewRegulation = hasPermission('events.regulation.view');
|
||||
|
||||
/** 省市两级 Cascader 数据(无区) */
|
||||
const provinceCityOptions = getProvinceCityCascaderOptions();
|
||||
|
||||
/** 渲染赛事名称(超长省略 + Tooltip) */
|
||||
const renderName = (text: string) => {
|
||||
const maxLen = 15;
|
||||
const raw = text || '';
|
||||
const display = raw.length > maxLen ? raw.slice(0, maxLen) + '...' : raw;
|
||||
return raw.length > maxLen ? (
|
||||
<Tooltip title={raw}>
|
||||
<span>{display}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{display}</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** 渲染操作列(纯视图,逻辑在 model 的 handleToggleShelf) */
|
||||
const renderAction = (record: any) => {
|
||||
const status = record.status;
|
||||
|
||||
if (status === '0' || status === '1') {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{canToggleStatus && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={togglingId.value === record.id}
|
||||
onClick={() => handleToggleShelf(record)}
|
||||
>
|
||||
下架
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === '4') {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{canToggleStatus && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={togglingId.value === record.id}
|
||||
onClick={() => handleToggleShelf(record)}
|
||||
>
|
||||
上架
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
/** 完整表格列 */
|
||||
const tableColumns = [
|
||||
...columns.map((col: any) => {
|
||||
if (col.dataIndex === 'name') {
|
||||
return {
|
||||
...col,
|
||||
customRender: ({ text }: { text: string }) => renderName(text),
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
{
|
||||
title: '赛事规程',
|
||||
key: 'regulation',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
customRender: ({ record }: { record: any }) => (
|
||||
<>
|
||||
{canViewRegulation ? (
|
||||
<a style={{ cursor: 'pointer' }} onClick={() => openRegulationModal(record)}>
|
||||
查看
|
||||
</a>
|
||||
) : (
|
||||
<span>--</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
customRender: ({ record }: { record: any }) => renderAction(record),
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<div class={pageStyles.containerMain}>
|
||||
<div class={pageStyles.filter}>
|
||||
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||
<Form.Item label="创建时间" name="createDateRange">
|
||||
<RangePicker
|
||||
value={filterForm.createDateRange as any}
|
||||
format="YYYY-MM-DD"
|
||||
valueFormat="YYYY-MM-DD"
|
||||
style={{ width: '260px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.createDateRange = val)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="赛事时间" name="startDateRange">
|
||||
<RangePicker
|
||||
value={filterForm.startDateRange as any}
|
||||
format="YYYY-MM-DD"
|
||||
valueFormat="YYYY-MM-DD"
|
||||
style={{ width: '260px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.startDateRange = val)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="赛事名称" name="name">
|
||||
<Input
|
||||
value={filterForm.name}
|
||||
placeholder="请输入赛事名称"
|
||||
style={{ width: '180px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.name = val.target?.value ?? val ?? '')}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="创建人" name="createName">
|
||||
<Input
|
||||
value={filterForm.createName}
|
||||
placeholder="请输入创建人"
|
||||
style={{ width: '150px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) =>
|
||||
(filterForm.createName = val.target?.value ?? val ?? '')
|
||||
}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
value={filterForm.status}
|
||||
options={EVENT_STATUS_OPTIONS as any}
|
||||
style={{ width: '150px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="公开活动" name="isPublic">
|
||||
<Select
|
||||
value={filterForm.isPublic}
|
||||
options={PUBLIC_EVENT_OPTIONS as any}
|
||||
style={{ width: '170px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.isPublic = val || '')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="证件号" name="idCardNo">
|
||||
<Select
|
||||
value={filterForm.idCardNo}
|
||||
options={ID_CARD_OPTIONS as any}
|
||||
style={{ width: '120px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.idCardNo = val || '')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="证件图片" name="idCardImg">
|
||||
<Select
|
||||
value={filterForm.idCardImg}
|
||||
options={ID_CARD_IMAGE_OPTIONS as any}
|
||||
style={{ width: '120px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.idCardImg = val || '')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="省市区域" name="region">
|
||||
<Cascader
|
||||
value={filterForm.region as any}
|
||||
options={provinceCityOptions}
|
||||
style={{ width: '220px' }}
|
||||
placeholder="请选择城市"
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.region = val || [])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource.value}
|
||||
loading={loading.value}
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.pagination}>
|
||||
<Pagination
|
||||
current={(pagination as any).current.value}
|
||||
pageSize={(pagination as any).pageSize.value}
|
||||
total={(pagination as any).total.value}
|
||||
showSizeChanger
|
||||
showTotal={(total: number) => `共 ${total} 条`}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{regulationModalVisible.value && (
|
||||
<EventRegulationModal
|
||||
visible={regulationModalVisible.value}
|
||||
record={currentRecord.value}
|
||||
onClose={closeRegulationModal}
|
||||
/>
|
||||
)}
|
||||
{detailModalVisible.value && (
|
||||
<EventDetailModal
|
||||
visible={detailModalVisible.value}
|
||||
record={currentRecord.value}
|
||||
onClose={closeDetailModal}
|
||||
/>
|
||||
)}
|
||||
{auditModalVisible.value && (
|
||||
<EventAuditModal
|
||||
visible={auditModalVisible.value}
|
||||
record={currentRecord.value}
|
||||
onClose={closeAuditModal}
|
||||
onSubmit={submitAudit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 赛事列表页配置
|
||||
* 职责:集中管理字段映射、选项配置等常量
|
||||
*/
|
||||
|
||||
import type { EventListQueryParams } from './services';
|
||||
|
||||
export const FILTER_DEFAULTS = {
|
||||
createDateRange: null as [string, string] | null,
|
||||
startDateRange: null as [string, string] | null,
|
||||
name: '',
|
||||
createName: '',
|
||||
status: '',
|
||||
region: [] as string[],
|
||||
isPublic: '',
|
||||
idCardNo: '',
|
||||
idCardImg: '',
|
||||
};
|
||||
|
||||
export type FieldMapping = [
|
||||
keyof typeof FILTER_DEFAULTS,
|
||||
keyof EventListQueryParams,
|
||||
((v: any) => any)?,
|
||||
];
|
||||
|
||||
export const FIELD_MAPPINGS: FieldMapping[] = [
|
||||
['name', 'name', (v) => v?.trim?.() ?? v],
|
||||
['createName', 'createName', (v) => v?.trim?.() ?? v],
|
||||
['status', 'status'],
|
||||
['isPublic', 'isPublic'],
|
||||
['idCardNo', 'idCardNo'],
|
||||
['idCardImg', 'idCardImg'],
|
||||
['region', 'areaName', (v) => v?.[v.length - 1]],
|
||||
['createDateRange', 'createDate', (v) => v?.[0]],
|
||||
['createDateRange', 'createDateEnd', (v) => v?.[1]],
|
||||
['startDateRange', 'startDate', (v) => v?.[0]],
|
||||
['startDateRange', 'startDateEnd', (v) => v?.[1]],
|
||||
];
|
||||
|
||||
/** 赛事状态选项(value 对应 API status) */
|
||||
export const EVENT_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '报名中' },
|
||||
{ value: '1', label: '进行中' },
|
||||
{ value: '2', label: '已结束' },
|
||||
{ value: '3', label: '已删除' },
|
||||
{ value: '4', label: '已下架' },
|
||||
] as const;
|
||||
|
||||
/** 公开活动选项(value 对应 API isPublic: 0=非公开,1=公开) */
|
||||
export const PUBLIC_EVENT_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '公开' },
|
||||
{ value: '0', label: '非公开' },
|
||||
] as const;
|
||||
|
||||
/** 证件号选项(value 对应 API idCardNo: 0=无需,1=需要) */
|
||||
export const ID_CARD_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '无需提供' },
|
||||
{ value: '1', label: '需提供' },
|
||||
] as const;
|
||||
|
||||
/** 证件图片选项(value 对应 API idCardImg: 0=无需,1=需要) */
|
||||
export const ID_CARD_IMAGE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '无需提供' },
|
||||
{ value: '1', label: '需提供' },
|
||||
] as const;
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 赛事审核 - 表单域控制器
|
||||
*
|
||||
* 集中管理赛事审核弹窗的提交校验逻辑。
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 提交校验
|
||||
// ============================================================
|
||||
|
||||
export interface EventAuditForm {
|
||||
approved: boolean;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface EventAuditSubmitCheckResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验赛事审核提交数据
|
||||
*
|
||||
* @param form 审核表单 { approved, comment }
|
||||
*/
|
||||
export function validateEventAuditSubmit(form: EventAuditForm): EventAuditSubmitCheckResult {
|
||||
// 不通过时强制要求填写审核内容
|
||||
if (!form.approved && !form.comment.trim()) {
|
||||
return { valid: false, error: '审核不通过时请填写审核内容' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 赛事列表 - 接口服务
|
||||
* 该页面使用的所有接口集中管理
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
EventListQueryParams,
|
||||
SignupListQueryParams,
|
||||
ToggleOnlineParams,
|
||||
ApiResult,
|
||||
PageData,
|
||||
TournamentAdminVO,
|
||||
TournamentAdminInfoVO,
|
||||
TournamentAdminInfoSignupVO,
|
||||
TournamentAdminRuleInfoVO,
|
||||
} from '@/api/events/types';
|
||||
|
||||
export type {
|
||||
TournamentAdminVO,
|
||||
TournamentAdminInfoVO,
|
||||
TournamentAdminInfoSignupVO,
|
||||
TournamentAdminRuleInfoVO,
|
||||
InnerCategoryVO,
|
||||
EventListQueryParams,
|
||||
SignupListQueryParams,
|
||||
ToggleOnlineParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from '@/api/events/types';
|
||||
|
||||
const eventList = '/admin/manager/tm/page';
|
||||
const eventDetail = '/admin/manager/tm/info';
|
||||
const signupList = '/admin/manager/tm/info/signupPage';
|
||||
const toggleOnline = '/admin/manager/tm/online';
|
||||
const ruleInfo = `/admin/manager/tm/ruleInfo`;
|
||||
|
||||
/** 赛事列表 - 列表分页 */
|
||||
export async function getEventList(
|
||||
params: EventListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminVO>>> {
|
||||
return get(eventList, params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** 赛事列表 - 赛事详情 */
|
||||
export async function getEventDetail(id: string): Promise<ApiResult<TournamentAdminInfoVO>> {
|
||||
return get(eventDetail, { id });
|
||||
}
|
||||
|
||||
/** 赛事列表 - 规则详情 */
|
||||
export async function getRuleInfo(id: string): Promise<ApiResult<TournamentAdminRuleInfoVO>> {
|
||||
return get(ruleInfo, { id });
|
||||
}
|
||||
|
||||
/** 赛事列表 - 赛事详情 - 人员列表 */
|
||||
export async function getSignupList(
|
||||
params: SignupListQueryParams,
|
||||
): Promise<ApiResult<PageData<TournamentAdminInfoSignupVO>>> {
|
||||
return get(signupList, params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** 赛事上架 / 下架 */
|
||||
export function toggleEventOnline(
|
||||
params: ToggleOnlineParams,
|
||||
): Promise<ApiResult<Record<string, never>>> {
|
||||
return post(toggleOnline, params);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { h } from 'vue';
|
||||
import { StatusTag, type StatusTagTone } from '@/components';
|
||||
import type { TournamentAdminVO } from './services';
|
||||
|
||||
/** 状态标签映射 */
|
||||
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||
'0': { label: '报名中', tone: 'primary' },
|
||||
'1': { label: '进行中', tone: 'cyan' },
|
||||
'2': { label: '已结束', tone: 'neutral' },
|
||||
'3': { label: '已删除', tone: 'neutral' },
|
||||
'4': { label: '已下架', tone: 'orange' },
|
||||
};
|
||||
|
||||
/** 0/1 → 显示文字 */
|
||||
const formatDisplayValue = (value: string, map: Record<string, string>) => map[value] ?? value;
|
||||
|
||||
const ID_CARD_DISPLAY_MAP: Record<string, string> = { '0': '否', '1': '是' };
|
||||
const IS_PUBLIC_DISPLAY_MAP: Record<string, string> = { '0': '非公开', '1': '公开' };
|
||||
|
||||
/**
|
||||
* 赛事列表 - 表格列配置(视图层)
|
||||
* 纯展示逻辑,不包含状态管理
|
||||
*/
|
||||
export function useEventListColumns() {
|
||||
const columns = [
|
||||
{ title: '赛事ID', dataIndex: 'id', key: 'id', width: 180 },
|
||||
{
|
||||
title: '赛事名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '赛事时间',
|
||||
key: 'eventTime',
|
||||
width: 260,
|
||||
customRender: ({ record }: { record: TournamentAdminVO }) =>
|
||||
`${record.startTimeBegin} 至 ${record.startTimeEnd}`,
|
||||
},
|
||||
{
|
||||
title: '省市区域',
|
||||
dataIndex: 'venueArea',
|
||||
key: 'venueArea',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '地点',
|
||||
dataIndex: 'venue',
|
||||
key: 'venue',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '是否公开',
|
||||
dataIndex: 'isPublic',
|
||||
key: 'isPublic',
|
||||
width: 100,
|
||||
customRender: ({ text }: { text: string }) => formatDisplayValue(text, IS_PUBLIC_DISPLAY_MAP),
|
||||
},
|
||||
{
|
||||
title: '是否提供证件号',
|
||||
dataIndex: 'isRequireIdCardNo',
|
||||
key: 'isRequireIdCardNo',
|
||||
width: 160,
|
||||
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
|
||||
},
|
||||
{
|
||||
title: '是否提供证件图片',
|
||||
dataIndex: 'isRequireIdCardImg',
|
||||
key: 'isRequireIdCardImg',
|
||||
width: 160,
|
||||
customRender: ({ text }: { text: string }) => formatDisplayValue(text, ID_CARD_DISPLAY_MAP),
|
||||
},
|
||||
{
|
||||
title: '创建人昵称',
|
||||
dataIndex: 'createNickname',
|
||||
key: 'createNickname',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '创建人手机号',
|
||||
dataIndex: 'createPhone',
|
||||
key: 'createPhone',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
customRender: ({ text }: { text: string }) => {
|
||||
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '报名人数',
|
||||
dataIndex: 'signupCount',
|
||||
key: 'signupCount',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '取消报名人数',
|
||||
dataIndex: 'dropoutCount',
|
||||
key: 'dropoutCount',
|
||||
width: 140,
|
||||
},
|
||||
];
|
||||
|
||||
return { columns };
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { computed, reactive, toRef, Ref, nextTick } from 'vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { useState, useDebounce } from '@/hooks';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
|
||||
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
|
||||
import type { TournamentAdminVO, EventListQueryParams, PageData, ApiResult } from './services';
|
||||
import { getEventList, toggleEventOnline } from './services';
|
||||
import {
|
||||
FILTER_DEFAULTS,
|
||||
FIELD_MAPPINGS,
|
||||
EVENT_STATUS_OPTIONS,
|
||||
PUBLIC_EVENT_OPTIONS,
|
||||
ID_CARD_OPTIONS,
|
||||
ID_CARD_IMAGE_OPTIONS,
|
||||
} from './config';
|
||||
|
||||
export { EVENT_STATUS_OPTIONS, PUBLIC_EVENT_OPTIONS, ID_CARD_OPTIONS, ID_CARD_IMAGE_OPTIONS };
|
||||
|
||||
/**
|
||||
* 赛事列表页数据模型
|
||||
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
|
||||
*/
|
||||
export function useEventListModel() {
|
||||
const filterForm = reactive({ ...FILTER_DEFAULTS });
|
||||
const [regulationModalVisible, setRegulationModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [auditModalVisible, setAuditModalVisible] = useState(false);
|
||||
const [currentRecord, setCurrentRecord] = useState<any>({});
|
||||
const [togglingId, setTogglingId] = useState('');
|
||||
|
||||
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
|
||||
const { debouncedValue: debouncedName } = useDebounce(toRef(filterForm, 'name') as Ref<string>, {
|
||||
delay: 300,
|
||||
});
|
||||
const { debouncedValue: debouncedCreator } = useDebounce(
|
||||
toRef(filterForm, 'createName') as Ref<string>,
|
||||
{ delay: 300 },
|
||||
);
|
||||
|
||||
const buildQueryParams = (): EventListQueryParams => {
|
||||
const { page, pageSize } = pagination.params.value;
|
||||
|
||||
const params = FIELD_MAPPINGS.reduce(
|
||||
(acc, [formKey, paramKey, transform]) => {
|
||||
const rawValue = filterForm[formKey];
|
||||
if (!hasValue(rawValue)) return acc;
|
||||
|
||||
const value = safeTransform(rawValue, transform);
|
||||
if (isEmptyValue(value)) return acc;
|
||||
|
||||
return { ...acc, [paramKey]: value };
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return { page: String(page), limit: String(pageSize), ...params } as EventListQueryParams;
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
run: fetchList,
|
||||
} = useRequest<ApiResult<PageData<TournamentAdminVO>>>(() => getEventList(buildQueryParams()), {
|
||||
refreshDeps: [],
|
||||
formatResult: (res) => res,
|
||||
});
|
||||
|
||||
// 提取列表数据
|
||||
const listData = computed<PageData<TournamentAdminVO> | undefined>(() => {
|
||||
const res = data.value;
|
||||
return res ? (res as any).data : undefined;
|
||||
});
|
||||
|
||||
const dataSource = computed(() => listData.value?.list || []);
|
||||
|
||||
// 同步分页总条数
|
||||
useEffect(() => {
|
||||
syncPaginationTotal(pagination, listData.value?.total);
|
||||
}, [listData]);
|
||||
|
||||
const hasFilter = computed(() => {
|
||||
const f = filterForm;
|
||||
return (
|
||||
!!f.createDateRange ||
|
||||
!!f.startDateRange ||
|
||||
!!debouncedName.value?.trim() ||
|
||||
!!debouncedCreator.value?.trim() ||
|
||||
!!f.status ||
|
||||
f.region.length > 0 ||
|
||||
!!f.isPublic ||
|
||||
!!f.idCardNo ||
|
||||
!!f.idCardImg
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.reset();
|
||||
fetchList();
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
Object.assign(filterForm, FILTER_DEFAULTS);
|
||||
pagination.reset();
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.setCurrent(page);
|
||||
if (pageSize !== (pagination as any).pageSize.value) {
|
||||
pagination.setPageSize(pageSize);
|
||||
}
|
||||
nextTick().then(() => fetchList());
|
||||
};
|
||||
|
||||
const openDetailModal = (record: any) => {
|
||||
setCurrentRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const openRegulationModal = (record: any) => {
|
||||
setCurrentRecord(record);
|
||||
setRegulationModalVisible(true);
|
||||
};
|
||||
|
||||
const openAuditModal = (record: any) => {
|
||||
setCurrentRecord(record);
|
||||
setAuditModalVisible(true);
|
||||
};
|
||||
|
||||
const closeRegulationModal = () => setRegulationModalVisible(false);
|
||||
const closeDetailModal = () => setDetailModalVisible(false);
|
||||
const closeAuditModal = () => setAuditModalVisible(false);
|
||||
|
||||
// TODO
|
||||
const submitAudit = (payload: { approved: boolean; comment: string }) => {
|
||||
console.log('提交审核:', currentRecord.value.id, payload);
|
||||
message.success('审核提交成功');
|
||||
setAuditModalVisible(false);
|
||||
};
|
||||
|
||||
const handleToggleShelf = (record: any) => {
|
||||
const isRemoved = record.status === '4';
|
||||
const actionText = isRemoved ? '上架' : '下架';
|
||||
const descText = isRemoved
|
||||
? `确认上架"${record.name}"吗?上架后用户可搜索和报名该赛事。`
|
||||
: `确认下架"${record.name}"吗?下架后用户将无法搜索和报名该赛事。`;
|
||||
|
||||
Modal.confirm({
|
||||
title: `${actionText}确认`,
|
||||
content: descText,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setTogglingId(record.id);
|
||||
try {
|
||||
const res = await toggleEventOnline({ id: record.id, online: isRemoved ? '1' : '0' });
|
||||
if (res.code === '200') {
|
||||
message.success(`${actionText}成功`);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || `${actionText}失败`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`${actionText}失败:`, e);
|
||||
} finally {
|
||||
setTogglingId('');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
hasFilter,
|
||||
regulationModalVisible,
|
||||
detailModalVisible,
|
||||
auditModalVisible,
|
||||
currentRecord,
|
||||
togglingId,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
openDetailModal,
|
||||
openRegulationModal,
|
||||
openAuditModal,
|
||||
closeRegulationModal,
|
||||
closeDetailModal,
|
||||
closeAuditModal,
|
||||
submitAudit,
|
||||
handleToggleShelf,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user