commit e8f0195b61525befeca62cbd7278c5faa18465d8 Author: WorkBuddy Date: Mon Aug 17 12:25:42 2026 +0800 chore: 前端首次基线提交 运行必须源码:src/、public/、index.html、package.json 及构建配置。 已忽略运行非必须:node_modules/、dist/、.env*。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c60af9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Env / secrets (defense-in-depth) +.env* +!.env.example + +# Agent / tooling state +.wb_*/ +.workbuddy/ diff --git a/index.html b/index.html new file mode 100644 index 0000000..0e96809 --- /dev/null +++ b/index.html @@ -0,0 +1,56 @@ + + + + + + + 卫星通信与导航应用技术创新赛 + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a08bc55 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1846 @@ +{ + "name": "competition-website", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "competition-website", + "version": "1.0.0", + "dependencies": { + "@vueuse/core": "^10.7.0", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.12", + "axios": "^1.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "spark-md5": "^3.0.2", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@transloadit/prettier-bytes": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz", + "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "license": "MIT" + }, + "node_modules/@uppy/companion-client": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz", + "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==", + "dependencies": { + "@uppy/utils": "^4.1.2", + "namespace-emitter": "^2.0.1" + } + }, + "node_modules/@uppy/core": { + "version": "2.3.4", + "resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz", + "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==", + "dependencies": { + "@transloadit/prettier-bytes": "0.0.7", + "@uppy/store-default": "^2.1.1", + "@uppy/utils": "^4.1.3", + "lodash.throttle": "^4.1.1", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^3.1.25", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/store-default": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz", + "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==" + }, + "node_modules/@uppy/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==", + "dependencies": { + "lodash.throttle": "^4.1.1" + } + }, + "node_modules/@uppy/xhr-upload": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz", + "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==", + "dependencies": { + "@uppy/companion-client": "^2.2.2", + "@uppy/utils": "^4.1.2", + "nanoid": "^3.1.25" + }, + "peerDependencies": { + "@uppy/core": "^2.3.3" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@wangeditor/basic-modules": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz", + "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==", + "dependencies": { + "is-url": "^1.2.4" + }, + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.throttle": "^4.1.1", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/code-highlight": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz", + "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==", + "dependencies": { + "prismjs": "^1.23.0" + }, + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/core": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz", + "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==", + "dependencies": { + "@types/event-emitter": "^0.3.3", + "event-emitter": "^0.3.5", + "html-void-elements": "^2.0.0", + "i18next": "^20.4.0", + "scroll-into-view-if-needed": "^2.2.28", + "slate-history": "^0.66.0" + }, + "peerDependencies": { + "@uppy/core": "^2.1.1", + "@uppy/xhr-upload": "^2.0.3", + "dom7": "^3.0.0", + "is-hotkey": "^0.2.0", + "lodash.camelcase": "^4.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "lodash.foreach": "^4.5.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "lodash.toarray": "^4.4.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/editor": { + "version": "5.1.23", + "resolved": "https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz", + "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==", + "dependencies": { + "@uppy/core": "^2.1.1", + "@uppy/xhr-upload": "^2.0.3", + "@wangeditor/basic-modules": "^1.1.7", + "@wangeditor/code-highlight": "^1.0.3", + "@wangeditor/core": "^1.1.19", + "@wangeditor/list-module": "^1.0.5", + "@wangeditor/table-module": "^1.1.4", + "@wangeditor/upload-image-module": "^1.0.2", + "@wangeditor/video-module": "^1.1.4", + "dom7": "^3.0.0", + "is-hotkey": "^0.2.0", + "lodash.camelcase": "^4.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "lodash.foreach": "^4.5.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "lodash.toarray": "^4.4.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/editor-for-vue": { + "version": "5.1.12", + "resolved": "https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz", + "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==", + "peerDependencies": { + "@wangeditor/editor": ">=5.1.0", + "vue": "^3.0.5" + } + }, + "node_modules/@wangeditor/list-module": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz", + "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==", + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/table-module": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz", + "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==", + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/upload-image-module": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz", + "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==", + "peerDependencies": { + "@uppy/core": "^2.0.3", + "@uppy/xhr-upload": "^2.0.3", + "@wangeditor/basic-modules": "1.x", + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.foreach": "^4.5.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/video-module": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz", + "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==", + "peerDependencies": { + "@uppy/core": "^2.1.4", + "@uppy/xhr-upload": "^2.0.7", + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom7": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz", + "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==", + "dependencies": { + "ssr-window": "^3.0.0-alpha.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/element-plus/node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "license": "MIT" + }, + "node_modules/element-plus/node_modules/@vueuse/core": { + "version": "14.3.0", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/element-plus/node_modules/@vueuse/metadata": { + "version": "14.3.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/shared": { + "version": "14.3.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz", + "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/i18next": { + "version": "20.6.1", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz", + "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==", + "dependencies": { + "@babel/runtime": "^7.12.0" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==" + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, + "node_modules/lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead." + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, + "node_modules/lodash.toarray": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz", + "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", + "dependencies": { + "wildcard": "^1.1.0" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/namespace-emitter": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz", + "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmmirror.com/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, + "node_modules/slate": { + "version": "0.72.8", + "resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz", + "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==", + "dependencies": { + "immer": "^9.0.6", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-history": { + "version": "0.66.0", + "resolved": "https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz", + "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/snabbdom": { + "version": "3.6.4", + "resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.4.tgz", + "integrity": "sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==", + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spark-md5": { + "version": "3.0.2", + "license": "(WTFPL OR MIT)" + }, + "node_modules/ssr-window": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz", + "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" + }, + "node_modules/typescript": { + "version": "6.0.3", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/wildcard": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz", + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a79154 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "competition-website", + "version": "1.0.0", + "private": true, + "description": "卫星通信与导航应用技术创新赛 - 前端项目", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vueuse/core": "^10.7.0", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.12", + "axios": "^1.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "spark-md5": "^3.0.2", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..772c259 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mock-word-template.docx b/public/mock-word-template.docx new file mode 100644 index 0000000..a144fe2 Binary files /dev/null and b/public/mock-word-template.docx differ diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..f216a09 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/api/admin.js b/src/api/admin.js new file mode 100644 index 0000000..ff86413 --- /dev/null +++ b/src/api/admin.js @@ -0,0 +1,350 @@ +import request from './request' + +// ==================== 管理员后台 API ==================== + +/** + * 获取管理后台统计数据 + * @returns {Promise} { totalAccounts, activatedAccounts, submissionsCount, ... } + */ +export function getStats(params = {}) { + return request.get('/admin/stats', { params }) +} + +/** + * 获取账号列表(含筛选) + * @param {object} params - { page, pageSize, keyword, group, status } + * @returns {Promise} { list, total, page, pageSize } + */ +export function getAccounts(params) { + return request.get('/admin/accounts', { params }) +} + +/** + * 导入 Excel 批量创建账号 + * @param {FormData} formData - 包含 Excel 文件的 FormData + * @returns {Promise} + */ +export function importExcel(formData) { + // 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上 + // `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary) + // 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。 + return request.post('/admin/import-excel', formData, { + timeout: 60000, + }) +} + +/** + * 获取参赛作品列表(按组别) + * @param {string} group - 组别标识 + * @returns {Promise} 作品列表 + */ +export function getSubmissions(group) { + return request.get(`/admin/submissions/${group}`) +} + +/** + * 获取比赛时间配置 + * @returns {Promise} + */ +export function getTimes() { + return request.get('/admin/times') +} + +/** + * 设置比赛时间 + * @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... } + * @returns {Promise} + */ +export function setTimes(data) { + // 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }] + // 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库 + const times = Object.entries(data).map(([group, item]) => ({ + group, + startTime: item.start, + endTime: item.end, + deadline: item.deadline, + choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50, + judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20, + })) + return request.put('/admin/times', { times }) +} + +/** + * 设置评分 + * @param {string} group - 组别标识 + * @param {object} data - 评分数据 + * @returns {Promise} + */ +export function setScores(group, data) { + return request.put(`/admin/scores/${group}`, data) +} + +/** + * 获取考题列表(管理员,含答案,按题型分表返回) + * @param {string} group - 组别标识 + * @returns {Promise} { choice: [...], judge: [...], word: [...] } + */ +export function getQuestions(group) { + return request.get(`/admin/exam/${group}/questions`) +} + +/** + * 获取作品管理统一列表(覆盖全部 4 个组别) + * @param {string} group - 组别标识 + * @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, files: [...] }] } + */ +export function getWorks(group) { + return request.get(`/admin/works/${group}`) +} + +/** + * 批量删除题目(按题型) + * @param {string} group - 组别标识 + * @param {string} questionType - choice / judge / word + * @param {number[]} ids - 题目 ID 数组 + * @returns {Promise} + */ +export function bulkDeleteQuestions(group, questionType, ids) { + return request.put(`/admin/exam/${group}/questions`, { + question_type: questionType, + action: 'bulkDelete', + question_uids: ids, + }) +} + +/** + * 设置考题(按题型分表管理) + * @param {string} group - 组别标识 + * @param {string} questionType - 题型:choice / judge / word + * @param {object} data - 考题数据 { questions: [...], action: 'add'|'update'|'delete' } + * @returns {Promise} + */ +export function setQuestions(group, questionType, data) { + return request.put(`/admin/exam/${group}/questions`, { + question_type: questionType, + ...data, + }) +} + +/** + * 客观题 Excel 批量导入(单工作表:题型/题目/选项A~D/正确答案/分值) + * @param {string} group - 组别标识 + * @param {FormData} formData - 包含 Excel 文件的 FormData(字段名 file) + * @returns {Promise} { imported, skipped, errors } + */ +export function importQuestionsExcel(group, formData) { + // 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。 + return request.post(`/admin/exam/${group}/questions/import-excel`, formData, { + timeout: 120000, + }) +} + +/** + * 上传 Word 答题模板 + * @param {string} group - 组别标识 + * @param {FormData} formData - 包含 Word 文件的 FormData + * @returns {Promise} + */ +export function uploadWordTemplate(group, formData) { + return request.put(`/admin/exam/${group}/word-template`, formData, { + timeout: 30000, + }) +} + +/** + * 获取 Word 模板信息 + * @param {string} group - 组别标识 + * @returns {Promise} + */ +export function getWordTemplate(group) { + return request.get(`/admin/exam/${group}/word-template`) +} + +/** + * 删除 Word 模板 + * @param {string} group - 组别标识 + * @returns {Promise} + */ +export function deleteWordTemplate(group) { + return request.delete(`/admin/exam/${group}/word-template`) +} + +// ==================== 备赛资料管理 ==================== + +/** + * 获取备赛资料列表 + * @param {string} group - 组别 + * @returns {Promise} + */ +export function getMaterials(group) { + return request.get(`/admin/materials/${group}`) +} + +/** + * 上传备赛资料 + * @param {string} group - 组别 + * @param {FormData} formData - 文件 + * @returns {Promise} + */ +export function uploadMaterial(group, formData) { + return request.post(`/admin/materials/${group}`, formData, { timeout: 60000 }) +} + +/** + * 删除备赛资料 + * @param {string} group - 组别 + * @param {string} fileName - 文件名 + * @returns {Promise} + */ +export function deleteMaterial(group, fileName) { + return request.delete(`/admin/materials/${group}/${encodeURIComponent(fileName)}`) +} + +// ==================== 成绩管理 ==================== + +/** + * 获取某组成绩列表(管理员) + * @param {string} group - 组别标识 + * @returns {Promise} 成绩列表 + */ +export function getScores(group) { + return request.get(`/admin/scores/${group}`) +} + +/** + * 发布/撤回指定组别的成绩 + * + * 发布状态按组独立存储:发布本科组不会让中职组考生看到成绩。 + * 不传 group 时对全部组生效(批量操作)。 + * @param {string} group - 组别标识 + * @param {boolean} publish - true: 发布, false: 撤回 + * @returns {Promise} + */ +export function publishScores(group, publish) { + return request.put('/admin/publish-scores', group ? { publish, group } : { publish }) +} + +/** + * 获取成绩发布状态 + * @param {string} group - 组别标识;不传时 published 表示「全部组均已发布」 + * @returns {Promise} { published, group, groups: { zhongzhi, gaozhi, benke, jiaoshi } } + */ +export function getPublishStatus(group) { + return request.get('/admin/publish-scores', group ? { params: { group } } : undefined) +} + +// ==================== 新闻管理 ==================== + +/** + * 获取新闻列表(管理员) + * @returns {Promise} + */ +export function listNews() { + return request.get('/admin/news') +} + +/** + * 创建新闻 + * @param {object} data - { title, content, category, coverImage } + * @returns {Promise} + */ +export function createNews(data) { + return request.post('/admin/news', data) +} + +/** + * 更新新闻 + * @param {number|string} id - 新闻 ID + * @param {object} data - 更新的字段 + * @returns {Promise} + */ +export function updateNews(id, data) { + return request.put(`/admin/news/${id}`, data) +} + +/** + * 删除新闻 + * @param {number|string} id - 新闻 ID + * @returns {Promise} + */ +export function deleteNews(id) { + return request.delete(`/admin/news/${id}`) +} + +/** + * 更新新闻排序 + * @param {Array<{id: number, sort_order: number}>} orders + * @returns {Promise} + */ +export function updateNewsOrder(orders) { + return request.put('/admin/news/order', { orders }) +} + +// ==================== 轮播图管理 ==================== + +/** + * 获取轮播图列表(管理员) + * @returns {Promise} + */ +export function listCarousel() { + return request.get('/admin/carousel') +} + +/** + * 添加轮播图 + * @param {object} data - { imageUrl, linkUrl, sortOrder, title } + * @returns {Promise} + */ +export function addCarousel(data) { + return request.post('/admin/carousel', data) +} + +/** + * 更新轮播图 + * @param {number|string} id - 轮播图 ID + * @param {object} data - 更新的字段 + * @returns {Promise} + */ +export function updateCarousel(id, data) { + return request.put(`/admin/carousel/${id}`, data) +} + +/** + * 删除轮播图 + * @param {number|string} id - 轮播图 ID + * @returns {Promise} + */ +export function deleteCarousel(id) { + return request.delete(`/admin/carousel/${id}`) +} + +/** + * 批量更新轮播图排序 + * @param {Array} orders - [{ id, sort_order }] + * @returns {Promise} + */ +export function updateCarouselOrder(orders) { + return request.put('/admin/carousel/order', { orders }) +} + +// ==================== 测试数据标记与重置 ==================== + +/** + * 切换选手的测试数据标记 + * @param {number} id - 选手ID + * @param {number} testFlag - 0=生产数据, 1=测试数据 + * @returns {Promise} + */ +export function toggleTestFlag(id, testFlag) { + return request.post('/admin/toggle-test-flag', { id, test_flag: testFlag }) +} + +/** + * 重置测试数据(仅重置 test_flag=1 的选手及其提交记录) + * @returns {Promise} + */ +export function resetTestData() { + return request.post('/admin/reset-test-data') +} + + diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..1dd096d --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,94 @@ +import request from './request' + +// ==================== 认证相关 API ==================== + +/** + * 获取图形验证码 + * @returns {Promise} 验证码图片数据 + */ +export function getCaptcha() { + return request.post('/auth/captcha') +} + +/** + * 发送短信验证码(激活流程) + * 前端传入身份证号,后端自动查找绑定手机号发送短信 + * @param {string} phone - 身份证号 + * @param {string} captcha - 图形验证码 + * @returns {Promise} { smsCode, phone, name, group } + */ +export function sendSms(phone, captcha) { + return request.post('/auth/sms', { phone, captcha }) +} + +/** + * 激活账号(设置密码) + * @param {object} data - { id_number, phone, password, smsCode } + * @returns {Promise} + */ +export function activate(data) { + return request.post('/auth/activate', data) +} + +/** + * 参赛者登录(身份证号 + 密码) + * @param {string} idNumber - 身份证号 + * @param {string} password - 密码 + * @returns {Promise} { token, user } + */ +export function login(phone, password) { + return request.post('/auth/login', { phone, password }) +} + +/** + * 管理员登录(用户名 + 密码) + * @param {string} username - 管理员用户名 + * @param {string} password - 密码 + * @returns {Promise} { token, user } + */ +export function adminLogin(username, password) { + return request.post('/auth/admin-login', { username, password }) +} + +/** + * 获取当前登录用户信息 + * @returns {Promise} 用户信息对象 + */ +export function getCurrentUser() { + return request.get('/auth/current-user') +} + +/** + * 忘记密码 - 发送短信验证码 + * @param {string} idNumber - 身份证号 + * @param {string} captcha - 图形验证码 + * @returns {Promise} { smsCode, phone } + */ +export function forgotPassword(idNumber, captcha) { + return request.post('/auth/forgot-password', { id_number: idNumber, captcha }) +} + +/** + * 重置密码 + * @param {object} data - { id_number, password, smsCode } + * @returns {Promise} + */ +export function resetPassword(data) { + return request.post('/auth/reset-password', data) +} + +/** + * 退出登录 + * @returns {Promise} + */ +export function logout() { + return request.post('/auth/logout') +} + +/** + * 静默刷新令牌(用户无感知续期,避免被强制踢出登录) + * @returns {Promise<{token: string, user: object}>} + */ +export function refreshToken() { + return request.post('/auth/refresh', null, { silent: true }) +} diff --git a/src/api/carousel.js b/src/api/carousel.js new file mode 100644 index 0000000..9a38d91 --- /dev/null +++ b/src/api/carousel.js @@ -0,0 +1,11 @@ +import request from './request' + +// ==================== 首页轮播图 API ==================== + +/** + * 获取首页轮播图列表 + * @returns {Promise} 轮播图数组 + */ +export function getCarouselList() { + return request.get('/carousel') +} diff --git a/src/api/exam.js b/src/api/exam.js new file mode 100644 index 0000000..10a7a69 --- /dev/null +++ b/src/api/exam.js @@ -0,0 +1,92 @@ +import request from './request' + +// ==================== 考试/知识竞答 API ==================== + +/** + * 获取考试状态 + * @param {string} group - 组别标识 + * @returns {Promise} { isActive, startTime, endTime, countdown, status, submitted } + */ +export function getExamStatus(group) { + return request.get(`/exam/${group}/status`) +} + +/** + * 获取考试题目列表 + * 抽题数量以后台 competition_times 配置为准(choice_count / judge_count), + * 题目数量不足时自动按题库实际数量封顶,题号随实际数量自适应。 + * @param {string} group - 组别标识 + * @returns {Promise} 题目数组 + */ +export function getQuestions(group) { + return request.get(`/exam/${group}/questions`) +} + +/** + * 获取 Word 答题模板(返回文件流,前端通过 window.open 直接下载) + * @param {string} group - 组别标识 + * @returns {Promise} 文件流 + */ +export function getWordTemplate(group) { + return request.get(`/exam/${group}/word-template`) +} + +/** + * 提交单个答案 + * @param {string} group - 组别标识 + * @param {array} answers - 答案数组 [{ question_uid, answer_data }] + * @returns {Promise} + */ +export function submitAnswers(group, answers) { + return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true }) +} + +/** + * 上传 Word 答题文件 + * @param {string} group - 组别标识 + * @param {FormData} formData - 包含 Word 文件的 FormData + * @returns {Promise} + */ +export function uploadWord(group, formData) { + return request.post(`/exam/${group}/upload-word`, formData, { + timeout: 60000, // 上传超时 60 秒 + }) +} + +/** + * 删除已上传的 Word 答题文件(中职/高职组) + * @param {string} group - 组别标识 + * @returns {Promise} + */ +export function deleteWord(group) { + return request.delete(`/exam/${group}/word`, { timeout: 60000 }) +} + +/** + * 提交全部答案(结束考试) + * @param {string} group - 组别标识 + * @param {object} data - 提交数据 + * @returns {Promise} + */ +export function submitAll(group, data) { + return request.post(`/exam/${group}/submit-all`, data, { silent: true }) +} + +/** + * 获取已有答案(考试重新进入时恢复状态) + * @param {string} group - 组别标识 + * @returns {Promise} { answers: [...] } + */ +export function getAnswers(group) { + return request.get(`/exam/${group}/answers`) +} + +/** + * 获取当前选手个人成绩(后端按认证用户取,group 仅作透传参数) + * GET /score + * @param {string} group - 组别标识 + * @returns {Promise} 成绩详情(含答题得分/作品评分/总分) + */ +export function getScore(group) { + return request.get('/score', { params: { group } }) +} diff --git a/src/api/materials.js b/src/api/materials.js new file mode 100644 index 0000000..f11c772 --- /dev/null +++ b/src/api/materials.js @@ -0,0 +1,13 @@ +import request from './request' + +// ==================== 资料下载 API(参赛者端) ==================== + +/** + * 获取可下载资料列表(按组别) + * @param {string} group - 组别标识 + * @returns {Promise} 资料列表 [{ id, fileName, name, size, type, uploadTime }] + */ +export function getMaterialsList(group) { + return request.get('/materials', { params: { group } }) +} + diff --git a/src/api/news.js b/src/api/news.js new file mode 100644 index 0000000..eeb4208 --- /dev/null +++ b/src/api/news.js @@ -0,0 +1,29 @@ +import request from './request' + +// ==================== 新闻资讯 API ==================== + +/** + * 获取新闻列表 + * @param {object} params - 查询参数 { page, pageSize, keyword, category } + * @returns {Promise} { list, total, page, pageSize } + */ +export function getNewsList(params) { + return request.get('/news', { params }) +} + +/** + * 获取新闻详情 + * @param {number|string} id - 新闻 ID + * @returns {Promise} 新闻详情对象 + */ +export function getNewsDetail(id) { + return request.get(`/news/${id}`) +} + +/** + * 获取各分组新闻计数 + * @returns {Promise} { all, zhongzhi, gaozhi, benke, jiaoshi } + */ +export function getNewsCounts() { + return request.get('/news/counts') +} diff --git a/src/api/request.js b/src/api/request.js new file mode 100644 index 0000000..d89e831 --- /dev/null +++ b/src/api/request.js @@ -0,0 +1,269 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { decodeJwtPayload } from '@/utils/jwt' + +// ==================== 创建 Axios 实例 ==================== + +const request = axios.create({ + // 所有请求的基础路径 + baseURL: '/api', + // 请求超时时间(毫秒) + timeout: 30000, + // 跨域请求时携带 cookie + withCredentials: false, +}) + +// ==================== 请求拦截器 ==================== + +request.interceptors.request.use( + (config) => { + // 从 localStorage 获取 token + const token = localStorage.getItem('token') + + // 如果存在 token,添加到请求头 + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + + return config + }, + (error) => { + // 请求错误时直接 reject + return Promise.reject(error) + }, +) + +// ==================== 静默刷新(防强制登出) ==================== +// 当请求返回 401 时,尝试用过期/临期的 token 静默换发新 token, +// 成功后自动重试原请求;仅当刷新也失败(真正失效)才清理并跳转登录。 +// 这样活跃用户永远不会被"意外踢回登录页"。 + +let isRefreshing = false +const refreshSubscribers = [] +// 已尝试过静默刷新的 token 集合:防止 axios 克隆 config 导致 _retry 标记丢失后, +// 重试请求再次触发刷新,形成"401→刷新→重试→401→再刷新"的刷新风暴。 +// 一次 401 最多:刷新 1 次 + 重试 1 次,之后该 token 直接判定为真正失效并登出。 +const refreshedTokens = new Set() + +function extractBearerToken(cfg) { + const h = (cfg && cfg.headers) || {} + const auth = h.Authorization || h.authorization || '' + const m = String(auth).match(/^Bearer\s+(.+)$/i) + if (m) return m[1] + return localStorage.getItem('token') || null +} + +function subscribeTokenRefresh(cb) { + refreshSubscribers.push(cb) +} +function onRefreshed(newToken) { + refreshSubscribers.forEach((cb) => cb(newToken)) + refreshSubscribers.length = 0 +} + +async function doSilentRefresh() { + // 刷新接口不挂 auth 中间件,故可直接用本实例调用 + const res = await request.post('/auth/refresh', null, { silent: true }) + // 防御:后端未部署 /api/auth/refresh(如返回 400/非标准响应)或刷新失败但走了 200 形态, + // 此时 res 没有 token。若不清,会把 localStorage 写成 undefined 并陷入"重试→401→再刷新"死循环。 + if (!res || !res.token) { + throw new Error('刷新接口未返回有效 token(可能后端未部署 /api/auth/refresh)') + } + const { token, user } = res + localStorage.setItem('token', token) + localStorage.setItem('user', JSON.stringify(user)) + // 同步 Pinia store(动态导入避免与 store 形成循环依赖) + try { + const { useAuthStore } = await import('@/stores/auth') + const authStore = useAuthStore() + authStore.persistAuth(token, user) + } catch (e) { + /* store 未初始化时忽略,localStorage 已更新即可 */ + } + return token +} + +function forceLogout() { + if (window.__authExpiredHandled) return + window.__authExpiredHandled = true + localStorage.removeItem('token') + localStorage.removeItem('user') + try { + import('@/stores/auth') + .then(({ useAuthStore }) => { + try { useAuthStore().clearAuth() } catch (e) {} + }) + .catch(() => {}) + } catch (e) {} + if (window.location.pathname !== '/login') { + ElMessage.error('登录已过期,请重新登录') + // 延迟跳转,给用户看到提示的时间 + setTimeout(() => { + window.location.href = '/login?reason=expired' + window.__authExpiredHandled = false + }, 1500) + } else { + window.__authExpiredHandled = false + } +} + +// ==================== 主动续期(防活跃用户被踢) ==================== +// 在应用启动、定时(默认 5 分钟)、以及标签页重新可见时检查: +// 若 token 即将过期(提前 10 分钟)或已过期但仍在后端宽限期内, +// 提前静默换发新 token,使活跃用户永远不会因为 token 过期而 401 被踢回登录页。 +const REFRESH_LEAD_SECONDS = 10 * 60 +const PROACTIVE_INTERVAL_MS = 5 * 60 * 1000 + +function shouldProactivelyRefresh() { + const token = localStorage.getItem('token') + if (!token) return false + const payload = decodeJwtPayload(token) + if (!payload || typeof payload.exp !== 'number') return false + const now = Math.floor(Date.now() / 1000) + // exp 落在提前量内(含已过期但尚未超过后端宽限期的情况) + return now >= payload.exp - REFRESH_LEAD_SECONDS +} + +async function proactiveRefreshOnce() { + if (!shouldProactivelyRefresh()) return + try { + await doSilentRefresh() + } catch (e) { + // 续期失败(token 真正失效/签名错误/超宽限):交还由后续请求或路由守卫处理, + // 不在此处强制跳转,避免打断用户。 + console.warn('[auth] 主动续期未成功,将在下次请求时重新鉴权:', e && e.message) + } +} + +let proactiveTimer = null +export function initProactiveTokenRefresh() { + // 启动即尝试一次(处理"昨天登录、今天才打开"的已过期但可续期 token) + proactiveRefreshOnce() + if (proactiveTimer) clearInterval(proactiveTimer) + proactiveTimer = setInterval(proactiveRefreshOnce, PROACTIVE_INTERVAL_MS) + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') proactiveRefreshOnce() + }) + } +} + +// ==================== 响应拦截器 ==================== + +request.interceptors.response.use( + (response) => { + // 服务端返回的数据在 response.data 中 + // 根据常见的后端响应格式解包: + // 如果后端用 { code, data, message } 格式,则提取 data + const res = response.data + + // 如果响应中明确包含 code 字段(说明是业务层封装) + if (res && typeof res === 'object' && 'code' in res) { + // 业务状态码非 0 / 200 时视为异常 + if (res.code !== 0 && res.code !== 200) { + // 支持 silent 模式:调用方可设置 config.silent = true 来抑制错误提示 + if (!response.config.silent) { + ElMessage.error(res.message || '请求失败') + } + return Promise.reject(new Error(res.message || '请求失败')) + } + // 正常返回,只暴露 data 部分 + return res.data !== undefined ? res.data : res + } + + // 如果后端直接返回数据(无 code 封装),原样返回 + return res + }, + async (error) => { + // 处理 HTTP 状态码错误 + if (error.response) { + const { status } = error.response + + switch (status) { + case 401: { + const cfg = error.config || {} + console.warn('[auth] 收到 401,尝试静默刷新续期(用户应无感知)') + + const reqToken = extractBearerToken(cfg) + + // 刷新接口自身 401:直接登出 + if (cfg.url && cfg.url.includes('/auth/refresh')) { + forceLogout() + return Promise.reject(error) + } + + // 该 token 已经刷新过且重试用仍 401:判定为真正失效(真过期 / 签名错 / + // 超宽限 / 后端未部署刷新接口),直接登出,绝不重复刷新。 + if (reqToken && refreshedTokens.has(reqToken)) { + console.warn('[auth] 该 token 已尝试静默刷新仍 401,判定为真正失效,请重新登录') + forceLogout() + return Promise.reject(error) + } + + // 并发 401:首个请求触发刷新,其余排队等待新 token 后重试 + if (isRefreshing) { + return new Promise((resolve, reject) => { + subscribeTokenRefresh((newToken) => { + if (newToken) { + cfg.headers = cfg.headers || {} + cfg.headers.Authorization = `Bearer ${newToken}` + resolve(request(cfg)) + } else { + reject(error) + } + }) + }) + } + + isRefreshing = true + try { + const newToken = await doSilentRefresh() + // 标记:旧 token 与新 token 都已尝试过刷新,重试若仍 401 直接登出 + if (reqToken) refreshedTokens.add(reqToken) + if (newToken) refreshedTokens.add(newToken) + onRefreshed(newToken) + cfg.headers = cfg.headers || {} + cfg.headers.Authorization = `Bearer ${newToken}` + return request(cfg) + } catch (refreshErr) { + const reason = + refreshErr && refreshErr.response && refreshErr.response.data + ? refreshErr.response.data.message + : (refreshErr && refreshErr.message) + console.warn('[auth] 静默刷新失败,用户将被请去重新登录。原因:', reason) + onRefreshed(null) + forceLogout() + return Promise.reject(refreshErr) + } finally { + isRefreshing = false + } + } + + case 403: + ElMessage.error('您没有权限执行此操作') + break + + case 404: + ElMessage.error('请求的资源不存在') + break + + case 500: + ElMessage.error('服务器内部错误,请稍后重试') + break + + default: + ElMessage.error(error.response.data?.message || `请求失败 (${status})`) + } + } else if (error.code === 'ECONNABORTED') { + // 请求超时 + ElMessage.error('请求超时,请检查网络连接') + } else { + // 网络错误或其他错误 + ElMessage.error('网络连接异常,请检查网络') + } + + return Promise.reject(error) + }, +) + +export default request diff --git a/src/api/score.js b/src/api/score.js new file mode 100644 index 0000000..78664b5 --- /dev/null +++ b/src/api/score.js @@ -0,0 +1,11 @@ +import request from './request' + +// ==================== 成绩查询 API ==================== + +/** + * 获取当前登录用户的成绩 + * @returns {Promise} { score, detail, rank, published } + */ +export function getMyScore() { + return request.get('/score') +} diff --git a/src/api/upload.js b/src/api/upload.js new file mode 100644 index 0000000..03fb053 --- /dev/null +++ b/src/api/upload.js @@ -0,0 +1,95 @@ +import request from './request' + +// ==================== 作品上传 API ==================== + +/** + * 上传作品分片 + * @param {string} group - 组别标识 + * @param {FormData} formData - 包含文件分片的 FormData + * @param {function} onProgress - 上传进度回调 (percent) + * @returns {Promise} + */ +export function uploadChunk(group, formData, onProgress) { + return request.post(`/upload/${group}/chunk`, formData, { + timeout: 120000, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const percent = Math.round( + (progressEvent.loaded / progressEvent.total) * 100 + ) + onProgress(percent) + } + }, + }) +} + +/** + * 合并分片并创建提交记录 + * @param {string} group - 组别标识 + * @param {object} data - { fileId, fileName, totalChunks, work_name } + * @returns {Promise} + */ +export function mergeChunks(group, data) { + return request.post(`/upload/${group}/merge`, data, { + timeout: 60000, + }) +} + +/** + * 查询分片上传状态 + * @param {string} group - 组别标识 + * @param {string} fileId - 文件唯一标识 + * @returns {Promise} { received, chunks } + */ +export function getChunkStatus(group, fileId) { + return request.get(`/upload/${group}/chunk-status`, { + params: { fileId }, + }) +} + +/** + * 获取上传状态 + * @param {string} group - 组别标识 + * @returns {Promise} { submitted, deadline, canSubmit } + */ +export function getUploadStatus(group) { + return request.get(`/upload/${group}/status`) +} + +/** + * 获取已提交的作品列表 + * @param {string} group - 组别标识 + * @returns {Promise} { list, total } + */ +export function getSubmissions(group) { + return request.get(`/upload/${group}/submissions`) +} + +/** + * 单次上传作品文件(流式,适用本科/教师组答案文件) + * @param {string} group - 组别标识 + * @param {FormData} formData - 含 file 与 file_type 字段 + * @param {function} onProgress - 进度回调 + * @returns {Promise} + */ +export function uploadWork(group, formData, onProgress) { + // 注意:不要手动设置 Content-Type: multipart/form-data(不带 boundary 会导致 + // 后端 ctx.getFileStream() 报 Missing boundary 而 500)。交由 axios/浏览器自动 + // 补带 boundary 的 multipart 头。字段顺序:先文本字段(file_type/work_name)再 file。 + return request.post(`/upload/${group}`, formData, { + timeout: 300000, + onUploadProgress: (e) => { + if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100)) + }, + }) +} + +/** + * 删除已上传的作品文件(本科/教师组) + * @param {string} group - 组别标识 + * @param {number|string} id - 提交记录 ID + * @returns {Promise} + */ +export function deleteWork(group, id) { + return request.delete(`/upload/${group}/${id}`, { timeout: 60000 }) +} \ No newline at end of file diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/typescript.svg b/src/assets/typescript.svg new file mode 100644 index 0000000..6c9d69c --- /dev/null +++ b/src/assets/typescript.svg @@ -0,0 +1 @@ + diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts new file mode 100644 index 0000000..5ff4cea --- /dev/null +++ b/src/auto-imports.d.ts @@ -0,0 +1,91 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue').EffectScope + const ElMessage: typeof import('element-plus/es').ElMessage + const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate + const computed: typeof import('vue').computed + const createApp: typeof import('vue').createApp + const createPinia: typeof import('pinia').createPinia + const customRef: typeof import('vue').customRef + const defineAsyncComponent: typeof import('vue').defineAsyncComponent + const defineComponent: typeof import('vue').defineComponent + const defineStore: typeof import('pinia').defineStore + const effectScope: typeof import('vue').effectScope + const getActivePinia: typeof import('pinia').getActivePinia + const getCurrentInstance: typeof import('vue').getCurrentInstance + const getCurrentScope: typeof import('vue').getCurrentScope + const getCurrentWatcher: typeof import('vue').getCurrentWatcher + const h: typeof import('vue').h + const inject: typeof import('vue').inject + const isProxy: typeof import('vue').isProxy + const isReactive: typeof import('vue').isReactive + const isReadonly: typeof import('vue').isReadonly + const isRef: typeof import('vue').isRef + const isShallow: typeof import('vue').isShallow + const mapActions: typeof import('pinia').mapActions + const mapGetters: typeof import('pinia').mapGetters + const mapState: typeof import('pinia').mapState + const mapStores: typeof import('pinia').mapStores + const mapWritableState: typeof import('pinia').mapWritableState + const markRaw: typeof import('vue').markRaw + const nextTick: typeof import('vue').nextTick + const onActivated: typeof import('vue').onActivated + const onBeforeMount: typeof import('vue').onBeforeMount + const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave + const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate + const onBeforeUnmount: typeof import('vue').onBeforeUnmount + const onBeforeUpdate: typeof import('vue').onBeforeUpdate + const onDeactivated: typeof import('vue').onDeactivated + const onErrorCaptured: typeof import('vue').onErrorCaptured + const onMounted: typeof import('vue').onMounted + const onRenderTracked: typeof import('vue').onRenderTracked + const onRenderTriggered: typeof import('vue').onRenderTriggered + const onScopeDispose: typeof import('vue').onScopeDispose + const onServerPrefetch: typeof import('vue').onServerPrefetch + const onUnmounted: typeof import('vue').onUnmounted + const onUpdated: typeof import('vue').onUpdated + const onWatcherCleanup: typeof import('vue').onWatcherCleanup + const provide: typeof import('vue').provide + const reactive: typeof import('vue').reactive + const readonly: typeof import('vue').readonly + const ref: typeof import('vue').ref + const resolveComponent: typeof import('vue').resolveComponent + const setActivePinia: typeof import('pinia').setActivePinia + const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix + const shallowReactive: typeof import('vue').shallowReactive + const shallowReadonly: typeof import('vue').shallowReadonly + const shallowRef: typeof import('vue').shallowRef + const storeToRefs: typeof import('pinia').storeToRefs + const toRaw: typeof import('vue').toRaw + const toRef: typeof import('vue').toRef + const toRefs: typeof import('vue').toRefs + const toValue: typeof import('vue').toValue + const triggerRef: typeof import('vue').triggerRef + const unref: typeof import('vue').unref + const useAttrs: typeof import('vue').useAttrs + const useCssModule: typeof import('vue').useCssModule + const useCssVars: typeof import('vue').useCssVars + const useId: typeof import('vue').useId + const useLink: typeof import('vue-router').useLink + const useModel: typeof import('vue').useModel + const useRoute: typeof import('vue-router').useRoute + const useRouter: typeof import('vue-router').useRouter + const useSlots: typeof import('vue').useSlots + const useTemplateRef: typeof import('vue').useTemplateRef + const watch: typeof import('vue').watch + const watchEffect: typeof import('vue').watchEffect + const watchPostEffect: typeof import('vue').watchPostEffect + const watchSyncEffect: typeof import('vue').watchSyncEffect +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/src/components.d.ts b/src/components.d.ts new file mode 100644 index 0000000..d9b7747 --- /dev/null +++ b/src/components.d.ts @@ -0,0 +1,54 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElHeader: typeof import('element-plus/es')['ElHeader'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElLink: typeof import('element-plus/es')['ElLink'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElResult: typeof import('element-plus/es')['ElResult'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTimeline: typeof import('element-plus/es')['ElTimeline'] + ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/src/components/RichEditor.vue b/src/components/RichEditor.vue new file mode 100644 index 0000000..0a08caa --- /dev/null +++ b/src/components/RichEditor.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..1314aca --- /dev/null +++ b/src/main.js @@ -0,0 +1,66 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +// Element Plus 中文语言包 +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +// Element Plus 样式 +import 'element-plus/dist/index.css' +// 全局样式(含 Element Plus 主题色变量,防止 getThemeColors 解构 undefined 报错) +import './styles/global.css' + +import App from './App.vue' +import router from './router' +// 主动 token 续期(防活跃用户因 token 过期被强制踢回登录页) +import { initProactiveTokenRefresh } from '@/api/request' +// 按钮防抖 / 节流 / 防重入:v-guard="() => handler(...)" +import { createClickGuard } from '@/utils/antiSpam' + +// 创建 Vue 应用实例 +const app = createApp(App) + +// 注册 Pinia 状态管理 +app.use(createPinia()) + +// 注册 Vue Router +app.use(router) + +// 启动主动 token 续期:活跃用户不会因 token 过期被强制踢出登录 +initProactiveTokenRefresh() + +// 注册 Element Plus(使用中文语言包) +app.use(ElementPlus, { + locale: zhCn, +}) + +// 全局指令 v-guard:为按钮/可点击元素提供「防重入 + 节流」, +// 防止狂点触发大量并发请求打爆连接池(2026-08-10 账号列表超时根因)。 +// 用法:下载 +// 会自行忽略 disabled 元素、执行期间重入、以及与上次间隔 < minInterval 的连点。 +app.directive('guard', { + mounted(el, binding) { + const opts = (binding.value && binding.value.__opts) || {} + el.__guardFn__ = typeof binding.value === 'function' ? binding.value : () => {} + // 间接调用:始终读取 el.__guardFn__ 的「最新」闭包, + // 避免 v-for 表格行复用时 mounted 期捕获的旧 row 被永久绑死(点删除删错行)。 + const guarded = createClickGuard(function (...args) { + return typeof el.__guardFn__ === 'function' ? el.__guardFn__.apply(this, args) : undefined + }, opts) + el.__guardHandler__ = guarded + // disabled 时浏览器不会派发 click,无需额外处理 + el.addEventListener('click', guarded) + }, + updated(el, binding) { + // 只换 handler,不重建 guard —— 节流计时与重入锁状态得以保留 + if (typeof binding.value === 'function') el.__guardFn__ = binding.value + }, + unmounted(el) { + if (el.__guardHandler__) { + el.removeEventListener('click', el.__guardHandler__) + delete el.__guardHandler__ + } + delete el.__guardFn__ + }, +}) + +// 挂载到 #app +app.mount('#app') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..07334f7 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,237 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { ElMessage } from 'element-plus' + +// ==================== 页面组件导入 ==================== + +// 公开页面(无需登录) +const Home = () => import('@/views/Home.vue') +const Login = () => import('@/views/Login.vue') +const Activate = () => import('@/views/Activate.vue') +const ForgotPassword = () => import('@/views/ForgotPassword.vue') +const NewsDetail = () => import('@/views/NewsDetail.vue') + +// 需要登录的页面 +const Dashboard = () => import('@/views/Dashboard.vue') +const Competition = () => import('@/views/Competition.vue') +const MockExam = () => import('@/views/MockExam.vue') +const NmeaParser = () => import('@/views/NmeaParser.vue') +const AtSimulator = () => import('@/views/AtSimulator.vue') +const Upload = () => import('@/views/Upload.vue') +const Materials = () => import('@/views/Materials.vue') +const Score = () => import('@/views/Score.vue') + +// 管理员页面 +const Admin = () => import('@/views/Admin.vue') + +// 404 页面 +const NotFound = () => import('@/views/NotFound.vue') + +// ==================== 路由配置 ==================== + +const routes = [ + // 首页(公开访问) + { + path: '/', + name: 'Home', + component: Home, + }, + + // 登录页(公开访问) + { + path: '/login', + name: 'Login', + component: Login, + }, + + // 激活账号页(公开访问) + { + path: '/activate', + name: 'Activate', + component: Activate, + }, + + // 忘记密码页(公开访问) + { + path: '/forgot-password', + name: 'ForgotPassword', + component: ForgotPassword, + }, + + // 新闻详情页(公开访问) + { + path: '/news/:id', + name: 'NewsDetail', + component: NewsDetail, + }, + + // ==================== 需要登录的路由 ==================== + + // 参赛者仪表盘 + { + path: '/dashboard', + name: 'Dashboard', + component: Dashboard, + meta: { requiresAuth: true }, + }, + + // 赛事信息页 + { + path: '/competition', + name: 'Competition', + component: Competition, + meta: { requiresAuth: true }, + }, + + // 模拟考试页(仅中职/高职组) + { + path: '/mock-exam', + name: 'MockExam', + component: MockExam, + meta: { requiresAuth: true }, + }, + + // NMEA-0183 协议解析工具(中职组实操) + { + path: '/nmea-parser', + name: 'NmeaParser', + component: NmeaParser, + meta: { requiresAuth: true }, + }, + + // AT 命令模拟器(高职组实操) + { + path: '/at-simulator', + name: 'AtSimulator', + component: AtSimulator, + meta: { requiresAuth: true }, + }, + + // 作品上传页 + { + path: '/upload', + name: 'Upload', + component: Upload, + meta: { requiresAuth: true }, + }, + + // 资料下载页 + { + path: '/materials', + name: 'Materials', + component: Materials, + meta: { requiresAuth: true }, + }, + + // 成绩查询页 + { + path: '/score', + name: 'Score', + component: Score, + meta: { requiresAuth: true }, + }, + + // ==================== 管理员路由 ==================== + + // 管理后台(需要管理员权限) + { + path: '/admin', + name: 'Admin', + component: Admin, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + + // ==================== 404 兜底 ==================== + + // 所有未匹配的路径重定向到 404 + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +// ==================== 创建路由实例 ==================== + +const router = createRouter({ + // 使用 HTML5 History 模式 + history: createWebHistory(), + routes, + // 路由切换时滚动到页面顶部 + scrollBehavior() { + return { top: 0 } + }, +}) + +// ==================== 全局前置守卫 ==================== + +router.beforeEach((to, from, next) => { + // 从 localStorage 读取 token,判断是否已登录 + const token = localStorage.getItem('token') + const isLoggedIn = !!token + + // 1. 检查是否需要登录权限 + if (to.matched.some((record) => record.meta.requiresAuth)) { + // 需要登录但未登录,重定向到登录页 + if (!isLoggedIn) { + next({ + path: '/login', + // 保留原目标路径,登录成功后跳转回去 + query: { redirect: to.fullPath }, + }) + return + } + + // 注:token 过期不再在导航阶段拦截。过期的 token 会由请求拦截器 + // 通过 /api/auth/refresh 静默换发新令牌并重试原请求,用户无感知, + // 不会被强制踢回登录页(仅当刷新也失败才跳转登录)。 + + // 2. 检查是否需要管理员权限 + if (to.matched.some((record) => record.meta.requiresAdmin)) { + // 从 localStorage 读取用户角色 + const userStr = localStorage.getItem('user') + let isAdmin = false + if (userStr) { + try { + const user = JSON.parse(userStr) + // 检查角色是否为管理员(role 字段或在 admins 数组中) + isAdmin = user.role === 'admin' || user.isAdmin === true + } catch (e) { + isAdmin = false + } + } + + // 非管理员访问管理后台,重定向到仪表盘 + if (!isAdmin) { + next({ path: '/dashboard' }) + return + } + } + } + + // 3. 已登录用户访问登录页,根据角色重定向到对应工作台 + if (isLoggedIn && to.path === '/login') { + // 从 localStorage 读取用户角色 + const userStr = localStorage.getItem('user') + let userRole = null + if (userStr) { + try { + const user = JSON.parse(userStr) + userRole = user.role + } catch (e) { + userRole = null + } + } + // 管理员 → 管理后台,选手 → 参赛者仪表盘 + if (userRole === 'admin') { + next({ path: '/admin' }) + } else { + next({ path: '/dashboard' }) + } + return + } + + // 放行 + next() +}) + +export default router diff --git a/src/stores/auth.js b/src/stores/auth.js new file mode 100644 index 0000000..6ca6627 --- /dev/null +++ b/src/stores/auth.js @@ -0,0 +1,156 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { useRouter } from 'vue-router' +import * as authApi from '@/api/auth' + +// ==================== 认证状态管理 ==================== + +export const useAuthStore = defineStore('auth', () => { + // ==================== 状态 ==================== + + // 当前用户信息 + const currentUser = ref( + JSON.parse(localStorage.getItem('user') || 'null') + ) + + // 认证令牌 + const token = ref(localStorage.getItem('token') || '') + + // ==================== 计算属性(Getters) ==================== + + // 是否已登录 + const isLoggedIn = computed(() => !!token.value) + + // 是否为管理员 + const isAdmin = computed(() => { + if (!currentUser.value) return false + return ( + currentUser.value.role === 'admin' || + currentUser.value.isAdmin === true + ) + }) + + // 用户名称(优先显示姓名,其次用户名) + const userName = computed(() => { + if (!currentUser.value) return '' + return currentUser.value.name || currentUser.value.username || '' + }) + + // 用户所属组别 + const userGroup = computed(() => { + return currentUser.value?.group || '' + }) + + // ==================== 私有辅助方法 ==================== + + /** + * 持久化存储 token 和用户信息到 localStorage + */ + function persistAuth(tokenValue, userData) { + token.value = tokenValue + currentUser.value = userData + localStorage.setItem('token', tokenValue) + localStorage.setItem('user', JSON.stringify(userData)) + } + + /** + * 清除本地存储的认证信息 + */ + function clearAuth() { + token.value = '' + currentUser.value = null + localStorage.removeItem('token') + localStorage.removeItem('user') + } + + // ==================== 操作方法(Actions) ==================== + + /** + * 参赛者登录(身份证号 + 密码) + * @param {string} idNumber - 身份证号 + * @param {string} password - 密码 + * @returns {Promise} 用户信息 + */ + async function login(phone, password) { + const result = await authApi.login(phone, password) + // 后端应返回 { token, user } + persistAuth(result.token, result.user) + return result.user + } + + /** + * 管理员登录 + * @param {string} username - 管理员用户名 + * @param {string} password - 密码 + * @returns {Promise} 管理员信息 + */ + async function adminLogin(username, password) { + const result = await authApi.adminLogin(username, password) + // 后端应返回 { token, user } + persistAuth(result.token, result.user) + return result.user + } + + /** + * 激活账号(设置密码) + * @param {object} data - { phone, password, smsCode } + * @returns {Promise} + */ + async function activate(data) { + const result = await authApi.activate(data) + // 激活成功后自动登录 + if (result.token && result.user) { + persistAuth(result.token, result.user) + } + return result + } + + /** + * 退出登录 + */ + async function logout() { + try { + // 尝试通知后端登出 + await authApi.logout() + } catch (e) { + // 即使后端请求失败,也清除本地状态 + console.warn('登出请求失败,仍将清除本地状态', e) + } + clearAuth() + // 跳转到登录页 + window.location.href = '/login' + } + + /** + * 从服务端获取最新用户信息 + * @returns {Promise} 用户信息 + */ + async function fetchCurrentUser() { + const user = await authApi.getCurrentUser() + currentUser.value = user + localStorage.setItem('user', JSON.stringify(user)) + return user + } + + // ==================== 导出 ==================== + + return { + // 状态 + currentUser, + token, + // 计算属性 + isLoggedIn, + isAdmin, + userName, + userGroup, + // 操作 + login, + adminLogin, + activate, + logout, + fetchCurrentUser, + // 工具方法 + clearAuth, + persistAuth, + } +}) diff --git a/src/stores/exam.js b/src/stores/exam.js new file mode 100644 index 0000000..bca0695 --- /dev/null +++ b/src/stores/exam.js @@ -0,0 +1,295 @@ +import { ref, computed, reactive } from 'vue' +import * as examApi from '@/api/exam' +import * as uploadApi from '@/api/upload' + +// ==================== 考试状态管理 ==================== + +const currentGroup = ref('') +const questions = ref([]) +const answers = ref({}) +const countdown = ref(0) +const examStarted = ref(false) +const wordTemplate = ref(null) +const wordUploaded = ref(false) +const totalQuestions = ref(0) + +// 考试 session 标识(防止返回重新进入) +let examSessionId = '' +let timerInterval = null +let resyncInterval = null +let finalCheckInFlight = false + +// ==================== 计算属性 ==================== + +const countdownDisplay = computed(() => { + const total = countdown.value + if (total <= 0) return '00:00' + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +}) + +const isTimeUp = computed(() => countdown.value <= 0 && examStarted.value) + +const progress = computed(() => { + if (questions.value.length === 0) return 0 + return Math.round((Object.keys(answers.value).length / questions.value.length) * 100) +}) + +const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice')) +const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge')) + +// ==================== 内部函数 ==================== + +function parseOptions(q) { + if (!q) return q + // options 可能是 JSON 字符串(TEXT 列),需要解析 + if (typeof q.options === 'string') { + try { q.options = JSON.parse(q.options) } catch (e) { q.options = [] } + } + if (!Array.isArray(q.options)) q.options = [] + // 后端已返回全局唯一的 question_uid(题型_自增id,如 choice_12 / judge_3)。 + // 这里仅作兜底:若后端未给,则按「题型_id」重建,避免 choice#1 与 judge#1 因纯数字 id 相同而键碰撞。 + if (q.id !== undefined && (q.question_uid === undefined || q.question_uid === null)) { + q.question_uid = (q.question_type ? q.question_type + '_' : '') + q.id + } + return q +} + +/** 与服务器对时:返回考试/提交状态(网络异常返回 null,继续本地倒计时兜底) */ +async function fetchServerStatus() { + if (!currentGroup.value) return null + try { + // 提交组(本科/教师)的剩余时间来自上传截止时间(GET /api/upload/:group/status 的 deadline), + // 与考试组(中职/高职)的 GET /api/exam/:group/status 不同口径,需按组别分支。 + if (['benke', 'jiaoshi'].includes(currentGroup.value)) { + const up = await uploadApi.getUploadStatus(currentGroup.value) + if (up && up.deadline) { + const deadline = new Date(up.deadline).getTime() + const cd = Math.max(0, Math.floor((deadline - Date.now()) / 1000)) + return { status: cd > 0 ? 'in_progress' : 'ended', countdown: cd } + } + return null + } + const st = await examApi.getExamStatus(currentGroup.value) + if (st && st.countdown !== undefined) return st + } catch (e) { /* 网络异常时忽略,本地倒计时继续 */ } + return null +} + +function startTimer() { + if (timerInterval) clearInterval(timerInterval) + if (resyncInterval) clearInterval(resyncInterval) + + timerInterval = setInterval(async () => { + if (countdown.value > 1) { + countdown.value-- + } else if (!finalCheckInFlight) { + // 即将归零:以服务器为最终权威再确认一次, + // 防止管理员中途延长 end_time 后客户端仍按旧倒计时提前自动交卷 + finalCheckInFlight = true + const st = await fetchServerStatus() + finalCheckInFlight = false + if (st && st.status === 'in_progress' && st.countdown > 0) { + countdown.value = st.countdown // 考试时间被延长,继续作答 + } else { + countdown.value = 0 // 服务器确认已结束(或网络不可达按本地时间兜底) + clearInterval(timerInterval) + timerInterval = null + if (resyncInterval) { clearInterval(resyncInterval); resyncInterval = null } + } + } + }, 1000) + + // 周期对时(30s):吸收本地计时漂移,并及时感知管理员延长/缩短考试时间 + resyncInterval = setInterval(async () => { + const st = await fetchServerStatus() + if (st && st.status === 'in_progress' && typeof st.countdown === 'number') { + countdown.value = st.countdown + } else if (st && st.status === 'ended') { + countdown.value = 0 + } + }, 30000) +} + +function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval) + timerInterval = null + } + if (resyncInterval) { + clearInterval(resyncInterval) + resyncInterval = null + } +} + +// ==================== 公开方法 ==================== + +async function startExam(group) { + stopExam() + + // 生成唯一的考试 session ID + examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + sessionStorage.setItem('exam_session', examSessionId) + + currentGroup.value = group + + const [status, questionList] = await Promise.allSettled([ + examApi.getExamStatus(group), + examApi.getQuestions(group), + ]) + + if (status.status === 'fulfilled' && status.value) { + const stateData = status.value + if (stateData.countdown !== undefined) { + countdown.value = stateData.countdown + } + } + + if (questionList.status === 'fulfilled' && questionList.value) { + // 解析 options(可能是 JSON 字符串) + // allSettled 包装后,value 是整个 response.data,再嵌套一层 data + const responseData = questionList.value + const rawQuestions = responseData.questions || [] + questions.value = rawQuestions.map(q => parseOptions(q)) + // 记录本次出题数量,提交时回传后端作为"应答题基准",避免与抽题数不一致 + totalQuestions.value = responseData.count || rawQuestions.length + } + + wordTemplate.value = { group } + + examStarted.value = true + startTimer() +} + +/** + * 启动提交组(本科/教师)倒计时:从上传截止时间推算剩余秒,复用同一套带 30s 对时、 + * 归零最终校验的定时器。提交组无客观题,故不拉取 questions。 + */ +async function startUploadCountdown(group) { + stopExam() + + examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + sessionStorage.setItem('exam_session', examSessionId) + + currentGroup.value = group + + try { + const up = await uploadApi.getUploadStatus(group) + if (up && up.deadline) { + const deadline = new Date(up.deadline).getTime() + countdown.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000)) + } + } catch (e) { /* 获取截止时间失败则保持 0,由对时兜底 */ } + + examStarted.value = true + startTimer() +} + +function stopExam() { + stopTimer() + examStarted.value = false + sessionStorage.removeItem('exam_session') +} + +/** + * 从 question_uid 推导题型(后端校验强制要求合法 question_type)。 + * uid 全局唯一化后形如 'choice_683' / 'judge_3',前缀即题型; + * 兜底再查 questions 列表(恢复会话等场景 questions 可能为空,前缀推导仍有效)。 + */ +function typeOfUid(questionUid) { + const uid = String(questionUid) + if (uid.startsWith('choice')) return 'choice' + if (uid.startsWith('judge')) return 'judge' + const q = questions.value.find(x => String(x.question_uid) === uid) + return q ? q.question_type : undefined +} + +async function submitAnswer(questionId, answer) { + // 本地先保存答案 + answers.value = { ...answers.value, [questionId]: answer } + + // 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝: + // 「答案格式错误,需要合法的 question_type(choice/judge)」) + try { + await examApi.submitAnswers(currentGroup.value, [ + { question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer }, + ]) + } catch (e) { + // 静默失败,不弹窗打扰用户答题 + console.warn('提交答案失败', e) + } +} + +async function submitAll(additionalData = {}) { + // 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空 + // 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。 + const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({ + question_uid: questionUid, + question_type: typeOfUid(questionUid), + answer_data: answer, + })) + const data = { + answers: answersArray, + totalQuestions: totalQuestions.value, + ...additionalData, + } + const result = await examApi.submitAll(currentGroup.value, data) + stopExam() + return result +} + +function resetExam() { + stopTimer() + questions.value = [] + answers.value = {} + countdown.value = 0 + examStarted.value = false + wordTemplate.value = null + wordUploaded.value = false + currentGroup.value = '' + examSessionId = '' + sessionStorage.removeItem('exam_session') +} + +/** 检查当前 session 是否有效 */ +function isSessionValid() { + const saved = sessionStorage.getItem('exam_session') + return saved === examSessionId && examSessionId !== '' +} + +/** 恢复已有答案(考试重新进入时使用) */ +function restoreAnswers(restoredAnswers) { + answers.value = { ...restoredAnswers } +} + +export function useExamStore() { + return reactive({ + // 状态 + currentGroup, + questions, + answers, + countdown, + examStarted, + wordTemplate, + wordUploaded, + totalQuestions, + // 计算属性 + countdownDisplay, + isTimeUp, + progress, + choiceQuestions, + judgeQuestions, + // 方法 + startExam, + startUploadCountdown, + stopExam, + submitAnswer, + submitAll, + resetExam, + isSessionValid, + restoreAnswers, + }) +} \ No newline at end of file diff --git a/src/stores/news.js b/src/stores/news.js new file mode 100644 index 0000000..371ed86 --- /dev/null +++ b/src/stores/news.js @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as newsApi from '@/api/news' + +// ==================== 新闻资讯状态管理 ==================== + +export const useNewsStore = defineStore('news', () => { + // ==================== 状态 ==================== + + // 新闻列表数据 + const newsList = ref([]) + + // 当前新闻分组/分类 + const currentGroup = ref('all') + + // 分页信息 + const pagination = ref({ + page: 1, + pageSize: 10, + total: 0, + }) + + // 加载状态 + const loading = ref(false) + + // ==================== 操作方法(Actions) ==================== + + /** + * 获取新闻列表 + * @param {object} params - 查询参数 { page, pageSize, keyword, category } + */ + async function fetchNews(params = {}) { + loading.value = true + try { + const queryParams = { + page: pagination.value.page, + pageSize: pagination.value.pageSize, + ...params, + } + + const result = await newsApi.getNewsList(queryParams) + + // 根据返回结构更新数据 + if (result && result.list) { + newsList.value = result.list + pagination.value.total = result.total || 0 + pagination.value.page = result.page || pagination.value.page + pagination.value.pageSize = result.pageSize || pagination.value.pageSize + } else if (Array.isArray(result)) { + // 如果直接返回数组 + newsList.value = result + pagination.value.total = result.length + } + + if (params?.category) { + currentGroup.value = params.category + } + } catch (e) { + console.warn('获取新闻列表失败', e) + newsList.value = [] + } finally { + loading.value = false + } + } + + // ==================== 导出 ==================== + + return { + // 状态 + newsList, + currentGroup, + pagination, + loading, + // 操作 + fetchNews, + } +}) diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..4cd518e --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,377 @@ +/* =========================================================== + 全局样式文件 (global.css) + 定义 CSS 变量、基础重置样式、通用工具类 + 适用于整个竞赛报名系统前端项目 + =========================================================== */ + +/* ============ CSS 变量定义 ============ */ +:root { + /* 主色调:深蓝色,用于导航栏、按钮等关键元素 */ + --primary-color: #1a3a5c; + /* 辅助色:金色,用于强调、高亮、获奖标识等 */ + --accent-color: #c9a84c; + /* 背景色:浅灰,用于页面整体背景 */ + --bg-color: #f5f6f8; + /* 文字主色 */ + --text-color: #303133; + /* 文字辅助色 */ + --text-secondary: #909399; + /* 边框色 */ + --border-color: #dcdfe6; + /* 成功色 */ + --success-color: #67c23a; + /* 警告色 */ + --warning-color: #e6a23c; + /* 危险色 */ + --danger-color: #f56c6c; + /* 信息色 */ + --info-color: #909399; + + /* 中文字体优先级 */ + --font-family: 'Noto Sans CJK SC', 'WenQuanYi Micro Hei', 'Microsoft YaHei', + sans-serif; + + /* 基础行高 */ + --line-height: 1.6; + + /* Element Plus 主题色变量(防止 getThemeColors 解构 undefined 报错) */ + --el-color-primary: #1a3a5c; + --el-color-primary-light-3: #4a6a8c; + --el-color-primary-light-5: #7a9abc; + --el-color-primary-light-7: #aacadc; + --el-color-primary-light-8: #c5dae8; + --el-color-primary-light-9: #e0eaf4; + --el-color-primary-dark-2: #152e4a; + --el-color-success: #67c23a; + --el-color-warning: #e6a23c; + --el-color-danger: #f56c6c; + --el-color-error: #f56c6c; + --el-color-info: #909399; + --el-color-white: #ffffff; + --el-color-black: #000000; + --el-bg-color: #ffffff; + --el-bg-color-page: #f5f6f8; + --el-bg-color-overlay: #ffffff; + --el-text-color-primary: #303133; + --el-text-color-regular: #606266; + --el-text-color-secondary: #909399; + --el-text-color-placeholder: #a8abb2; + --el-border-color: #dcdfe6; + --el-border-color-light: #e4e7ed; + --el-border-color-lighter: #ebeef5; + --el-border-color-extra-light: #f2f6fc; + --el-fill-color: #f0f2f5; + --el-fill-color-light: #f5f7fa; + --el-fill-color-blank: #ffffff; +} + +/* ============ 基础重置 ============ */ + +/* 全局盒模型重置:所有元素使用 border-box 计算宽高 */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* HTML 根元素设置 */ +html { + /* 平滑滚动效果 */ + scroll-behavior: smooth; + /* 防止 iOS 弹性滚动导致的问题 */ + -webkit-overflow-scrolling: touch; +} + +/* 页面主体样式 */ +body { + /* 字体 */ + font-family: var(--font-family); + /* 字号 */ + font-size: 14px; + /* 字体颜色 */ + color: var(--text-color); + /* 背景色 */ + background-color: var(--bg-color); + /* 行高 */ + line-height: var(--line-height); + /* 文本渲染优化 */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* 最小宽度 */ + min-width: 320px; +} + +/* 链接默认样式重置 */ +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + color: var(--accent-color); +} + +/* 图片默认样式 */ +img { + max-width: 100%; + height: auto; + /* 去除图片底部间隙 */ + vertical-align: middle; +} + +/* 列表样式重置 */ +ul, +ol { + list-style: none; +} + +/* 表单元素字体继承 */ +input, +button, +textarea, +select { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +/* 清除 input 自动填充的背景色 */ +input:-webkit-autofill { + -webkit-box-shadow: 0 0 0 1000px white inset !important; +} + +/* ============ 通用工具类 ============ */ + +/* 文字居中对齐 */ +.text-center { + text-align: center; +} + +/* 文字左对齐 */ +.text-left { + text-align: left; +} + +/* 文字右对齐 */ +.text-right { + text-align: right; +} + +/* 外边距工具类 - 顶部 */ +.mt-10 { + margin-top: 10px; +} + +.mt-20 { + margin-top: 20px; +} + +.mt-30 { + margin-top: 30px; +} + +/* 外边距工具类 - 底部 */ +.mb-10 { + margin-bottom: 10px; +} + +.mb-20 { + margin-bottom: 20px; +} + +.mb-30 { + margin-bottom: 30px; +} + +/* 外边距工具类 - 左侧 */ +.ml-10 { + margin-left: 10px; +} + +.ml-20 { + margin-left: 20px; +} + +/* 外边距工具类 - 右侧 */ +.mr-10 { + margin-right: 10px; +} + +.mr-20 { + margin-right: 20px; +} + +/* 内边距工具类 */ +.p-10 { + padding: 10px; +} + +.p-20 { + padding: 20px; +} + +.p-30 { + padding: 30px; +} + +/* 弹性布局容器:水平排列 */ +.flex { + display: flex; +} + +/* 弹性布局 + 水平垂直居中 */ +.flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +/* 弹性布局 + 两端对齐(垂直居中) */ +.flex-between { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* 弹性布局 + 垂直方向 */ +.flex-column { + display: flex; + flex-direction: column; +} + +/* 弹性布局列居中 */ +.flex-column-center { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +/* 文字溢出省略(单行) */ +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 宽度工具类 */ +.w-full { + width: 100%; +} + +/* 隐藏元素 */ +.hidden { + display: none; +} + +/* 卡片容器通用样式 */ +.card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); + padding: 20px; +} + +/* 页面标题通用样式 */ +.page-title { + font-size: 24px; + font-weight: 600; + color: var(--primary-color); + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 2px solid var(--accent-color); +} + +/* 内容区域包装器 */ +.content-wrapper { + max-width: 1200px; + margin: 0 auto; +} + +/* ============ 滚动条美化 ============ */ + +/* 整个滚动条 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +/* 滚动条轨道 */ +::-webkit-scrollbar-track { + background: transparent; +} + +/* 滚动条滑块 */ +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} + +/* 滚动条滑块悬停 */ +::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.25); +} + +/* ============ Element Plus 图标+文字按钮居中对齐兜底 ============ + 说明:Element Plus 会把按钮 slot 包进 .el-button__content 并设 + display:inline-flex;align-items:center,按规范图标与文字应垂直居中。 + 但本项目的全局重置 button{line-height:inherit} 与部分组件 CSS 加载 + 顺序可能导致个别环境下内部容器未正确居中(图标回退 baseline 对齐、 + 文字显得偏高或偏左)。此处用 !important 强制按钮内部内容严格居中, + 并令 .el-button__content 占满按钮宽度,使 justify-content 真正生效。 + 覆盖范围:全项目所有 .el-button(含 el-link 型“返回”链接)。 */ +.el-button, +.el-button > span, +.el-button .el-button__content { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; +} +.el-button .el-button__content { + width: 100% !important; +} +.el-button .el-icon { + vertical-align: middle !important; + flex-shrink: 0 !important; +} +.el-button .el-icon + span { + margin-left: 6px !important; +} +.el-link, +.el-link > span, +.el-link .el-link__inner { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; +} + +/* =========================================================== + 文字型按钮 hover 态对比度保障(全局,根治「白底白字」) + ----------------------------------------------------------- + 根因(已取证):Element Plus 的规则为 + element-plus/dist/index.css: + .el-button.is-text:not(.is-disabled):hover{background-color:var(--el-fill-color-light)} + --el-fill-color-light:#f5f7fa ← 不透明的「近白」底 + 该选择器有 4 个类级特异性,高于页面内 .back-btn:hover + (1 个类 + scoped [data-v-x] = 2 个类级),因此必然胜出,导致两类不可读: + ① 页面自定义的「深色底 + 白字」hover:深色底被近白底压掉, + 白字留下 → 白底白字,完全不可读; + 已复现:选手工作台 Dashboard.vue「返回首页」。 + ② 深色页头上的白字文字按钮(未自定义 hover 颜色): + hover 时铺上近白底,白字同样不可读; + 已复现:管理后台 Admin.vue 顶栏「返回首页」(inline color 白色、无 hover 覆盖)。 + + 全局对策:把「不透明浅色填充」换成「半透明中性底」。 + · 半透明 → 深色页头上呈轻微变亮、浅色页面上呈轻微变暗, + 始终与所在背景同色系,不会凭空造出与文字同色的实底; + · 中性灰蓝 → 不引入新色相,不干扰各页面既有配色; + · 仍保留清晰的 hover 反馈,不牺牲可感知性。 + 本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。 + 新增页面自动继承此保障,无需逐页排查。 + =========================================================== */ +.el-button.is-text:not(.is-disabled):hover, +.el-button.is-text:not(.is-disabled):focus { + background-color: rgba(120, 130, 145, 0.16); +} \ No newline at end of file diff --git a/src/utils/antiSpam.js b/src/utils/antiSpam.js new file mode 100644 index 0000000..3c6bbc5 --- /dev/null +++ b/src/utils/antiSpam.js @@ -0,0 +1,87 @@ +// ==================== 按钮防抖 / 节流 / 防重入 ==================== +// 背景(生产事故 2026-08-10):作品管理狂点「下载」会瞬间发起大量并发 blob 请求, +// 占满浏览器同域连接池(约 6 条),导致「获取账号列表」等其它请求排队 30s 超时。 +// 除下载层信号量(见 utils/download.js)外,所有触发网络请求的按钮都应做 +// 「防重入 + 节流」,避免同一次用户意图被重复执行。 +// +// 用法: +// import { createClickGuard } from '@/utils/antiSpam' +// const guarded = createClickGuard(rawHandler, { minInterval: 600 }) +// // 见 main.js 全局指令 + +/** + * 防抖:连续触发只在最后一次等待 wait 后执行(immediate=true 则首次立即执行)。 + */ +export function debounce(fn, wait = 300, immediate = false) { + let timer = null + return function debounced(...args) { + const callNow = immediate && !timer + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + if (!immediate) fn.apply(this, args) + }, wait) + if (callNow) fn.apply(this, args) + } +} + +/** + * 节流:限制 fn 在 limit 毫秒内最多执行一次(首尾都尽量触发)。 + */ +export function throttle(fn, limit = 500) { + let last = 0 + let timer = null + return function throttled(...args) { + const now = Date.now() + const remaining = limit - (now - last) + if (remaining <= 0) { + if (timer) { clearTimeout(timer); timer = null } + last = now + fn.apply(this, args) + } else if (!timer) { + timer = setTimeout(() => { + last = Date.now() + timer = null + fn.apply(this, args) + }, remaining) + } + } +} + +/** + * 点击守卫:包裹一个处理函数,提供「防重入 + 节流」双保险。 + * - lock(默认开):上一次执行尚未结束(Promise 未 settle)时,忽略后续点击, + * 杜绝「一次点击被当成多次」或「并发重入」。 + * - minInterval:两次执行最小间隔(毫秒),过频的连点被忽略(节流)。 + * 适配同步与异步(Promise)处理函数。 + * + * @param {Function} fn 原始点击处理函数 + * @param {{minInterval?:number, lock?:boolean}} [opts] + * @returns {Function} + */ +export function createClickGuard(fn, opts = {}) { + const { minInterval = 500, lock = true } = opts + let lastRun = 0 + let running = false + + return function guarded(...args) { + const now = Date.now() + if (lock && running) return // 上一次还在执行 → 直接忽略 + if (now - lastRun < minInterval) return // 太频繁 → 节流忽略 + lastRun = now + if (lock) running = true + + let ret + try { + ret = fn.apply(this, args) + } catch (e) { + ret = Promise.reject(e) + } + + if (ret && typeof ret.then === 'function') { + return ret.finally(() => { running = false }) + } + running = false + return ret + } +} diff --git a/src/utils/download.js b/src/utils/download.js new file mode 100644 index 0000000..3a0abdb --- /dev/null +++ b/src/utils/download.js @@ -0,0 +1,294 @@ +import axios from 'axios' +import { ElMessage, ElLoading } from 'element-plus' + +// ==================== 安全文件下载封装 ==================== +// 背景:后端业务错误信封统一为 HTTP 200 + JSON({ code, message })。 +// 前端若用 responseType:'blob' 下载且不做校验,会把「文件缺失/服务端错误」的 +// JSON 直接存成 .docx/.zip/.csv,导致用户打开时提示「文件已损坏/无法打开」。 +// +// 本封装统一解决: +// 1. 使用独立 axios 实例,不挂全局响应拦截器,可直接拿到 response.status / headers; +// 2. 自行判断成功/失败(validateStatus 恒 true),不被 axios 把 4xx/5xx 当异常吞掉; +// 3. 即便后端误返回 200 + JSON,也依靠 content-type 识别并抛出业务错误,绝不落盘。 + +const dl = axios.create({ + baseURL: '/api', + timeout: 300000, + withCredentials: false, + validateStatus: () => true, +}) + +dl.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +// ==================== 并发控制(根治连接池耗尽) ==================== +// 背景:浏览器对同域并发请求有上限(约 6 条)。狂点「下载」会在极短时间内发起大量 +// 流式 blob 请求,占满连接池,导致「获取账号列表」等其它请求排队直至 30s 超时。 +// 这里用模块级信号量把并发下载限制到 MAX_CONCURRENT_DOWNLOADS,并对同一 URL 去重 +// (进行中的下载忽略重复点击),从源头掐掉连接池被挤爆的可能。 +const MAX_CONCURRENT_DOWNLOADS = 3 +let _activeDownloads = 0 +const _downloadWaitQueue = [] + +function _acquireDownloadSlot() { + return new Promise((resolve) => { + if (_activeDownloads < MAX_CONCURRENT_DOWNLOADS) { + _activeDownloads++ + resolve() + } else { + _downloadWaitQueue.push(resolve) + } + }) +} + +function _releaseDownloadSlot() { + _activeDownloads-- + if (_downloadWaitQueue.length) { + _activeDownloads++ + _downloadWaitQueue.shift()() + } +} + +// 同一 URL 进行中的下载记录:重复点击直接忽略,避免连点产生多份下载流 +const _inFlightDownloads = new Set() + +/** + * 受控 GET:在信号量许可内发请求,并对同 URL 去重。 + * @returns {Promise} + */ +async function guardedGet(url, timeout, onProgress) { + const norm = normalizeUrl(url) + if (_inFlightDownloads.has(norm)) { + // 同一文件正在下载,忽略这次重复点击(不抛错、不占连接) + throw new Error('__DUP__') + } + _inFlightDownloads.add(norm) + try { + await _acquireDownloadSlot() + try { + return await dl.get(norm, { + responseType: 'blob', + timeout, + // 透传下载进度(1M 带宽下大文件耗时久,让调用方有反馈,消除「无反应」错觉) + onDownloadProgress: onProgress, + }) + } finally { + _releaseDownloadSlot() + } + } finally { + _inFlightDownloads.delete(norm) + } +} + +function getHeader(headers, name) { + if (!headers) return '' + const lower = name.toLowerCase() + // axios 小写化后的键 + if (headers[lower]) return headers[lower] + // 兜底:遍历一次(极少数环境下大小写保留) + for (const k of Object.keys(headers)) { + if (k.toLowerCase() === lower) return headers[k] + } + return '' +} + +// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。 +function normalizeUrl(url) { + if (url && url.startsWith('/api')) return url.slice(4) + return url +} + +function extractFilename(disposition, fallback) { + if (!disposition) return fallback + // 百分号解码(失败时回退原串,避免把合法字面量 % 也破坏) + const safeDecode = (s) => { + try { return decodeURIComponent(s) } catch (e) { return s } + } + // 1) 优先 RFC 5987: filename*=UTF-8''<百分号编码值> + const star = disposition.match(/filename\*=(?:UTF-8'')?([^;]+)/i) + if (star) { + const v = star[1].trim().replace(/^["']|["']$/g, '') + const p = v.indexOf("''") // 去掉可能的 charset'' 前缀 + return safeDecode(p !== -1 ? v.slice(p + 2) : v) + } + // 2) 兜底 filename="<可能被百分号编码的中文名>"(如后端 encodeURIComponent 后写入) + const plain = disposition.match(/filename="?([^";]+)"?/i) + if (plain) return safeDecode(plain[1].trim()) + return fallback +} + +function triggerDownload(blob, filename, originalUrl) { + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + + // 跨设备兜底:部分移动端浏览器(如 iOS Safari)会静默忽略「blob + a.download」, + // 导致点击下载毫无反应。此时改用浏览器原生导航下载——后端已支持 ?token= 回退鉴权。 + // 该分支在用户手势同步调用链内执行,不会被浏览器弹窗拦截。 + const supportsDownload = 'download' in document.createElement('a') + const ua = navigator.userAgent || '' + const isIOS = /iP(ad|hone|od)/.test(ua) + if ((!supportsDownload || isIOS) && originalUrl) { + const sep = originalUrl.includes('?') ? '&' : '?' + const token = localStorage.getItem('token') || '' + window.open(originalUrl + sep + 'token=' + encodeURIComponent(token), '_blank') + } + + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000) +} + +// ==================== 内容真实性校验(防「假成功」落盘) ==================== +// 背景(2026-08-12 生产事故):管理后台下载考生上传的大文件,得到一个 ≈2KB 的文件, +// 打开提示「文件不可用 / 已损坏 / 内容为空」;而备赛资料下载完全正常。 +// 根因:作品/答案下载走后端 _sendFile 的 X-Accel-Redirect 分支(由 nginx 直接吐文件), +// 生产 nginx 缺少 /internal-downloads/ 内部 location(该配置是后加的、未 reload), +// 内部跳转落到 SPA 回退 `try_files $uri $uri/ /index.html`,nginx 把前端 +// index.html(≈2140 字节 HTML)当文件吐回,且仍带着后端设置的 +// Content-Disposition + office Content-Type —— +// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。 +// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误): +// ① HTML 嗅探:Content-Type 为 text/html,或正文以 ])/i.test(head) + } catch (e) { + /* 读取头部失败不阻断正常下载 */ + } + if (looksHtmlByCt || looksHtmlByMagic) { + return '服务器返回的是网页而非文件,下载通道配置异常(请联系管理员检查 nginx 的内部下载 location 是否已生效)' + } + + // ② 大小哨兵:与服务端 stat 的真实字节数比对 + const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10) + if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) { + return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试` + } + + return null +} + +async function blobToMessage(blob) { + try { + const text = await blob.text() + // 🔴 防御纵深:若响应体是 HTML(理论上 assertRealFile 已在步骤 0 拦截,这里兜底), + // 绝不直接回显标签,避免弹窗泄漏 …nginx… 原文。 + if (/^\s*<(!doctype\s+html|])/i.test(text)) { + return '服务器返回了网页而非文件,下载通道配置异常(请联系管理员检查 nginx 内部下载 location 是否已生效)' + } + try { + const j = JSON.parse(text) + if (j && j.message) return j.message + } catch (e) { + /* 非 JSON,回退原文前 200 字 */ + } + return text.slice(0, 200) || '下载失败' + } catch (e) { + return '下载失败' + } +} + +/** + * 安全下载:校验 HTTP 状态 + 响应类型,绝不让错误 JSON 被存成文件。 + * @param {string} url 相对 /api 的路径(可带或不带 /api 前缀,实例已带 baseURL) + * @param {object} opts { fallbackName, timeout, onError, onSuccess } + * @returns {Promise} 是否成功触发了下载 + */ +export async function safeDownload(url, opts = {}) { + // 🔴 带宽适配:生产服务器仅 1M 带宽(≈125KB/s),120s 仅能传 ~15MB。 + // 作品/答案文件常达数十 MB,原 120000 超时必然触发「timeout of 120000ms exceeded」。 + // 改为不超时(0),由 nginx proxy_read_timeout(已放大到 600s)与用户耐心兜底;调用方仍可按需覆盖。 + const { fallbackName = 'download', timeout = 0, onError, onSuccess, onProgress } = opts + const fail = (msg) => { if (onError) onError(msg); else ElMessage.error(msg); return false } + // 提前去重提示:同一文件正在下载时,给用户明确反馈,避免「点击无反应」错觉 + if (_inFlightDownloads.has(normalizeUrl(url))) { + ElMessage.info('该文件正在下载中,请稍候…') + return false + } + const loading = ElLoading.service({ lock: true, text: '文件下载中,请稍候(1M 带宽下大文件较慢)' }) + try { + const res = await guardedGet(url, timeout, onProgress) + + // 0) 🔴 内容真实性校验必须优先于「状态码 / JSON」判断(顺序与 fetchBlobGuarded 对齐)。 + // nginx 404 / SPA 回退会返回 text/html 错误页(如 …404 Not Found…nginx…), + // 若先走状态码分支会把整段 HTML 原标签塞进 message,再被 ElMessage 裸漏到弹窗。 + // 命中 HTML 错误页 / 与 X-File-Size 哨兵不符 → 一律返回干净提示,绝不泄漏标签。 + const contentErr = await assertRealFile(res) + if (contentErr) return fail(contentErr) + + // 1) HTTP 状态码非 2xx(响应体已确认非 HTML,多半是后端 JSON 错误信封) + if (res.status < 200 || res.status >= 300) { + return fail((await blobToMessage(res.data)) || `下载失败 (${res.status})`) + } + + // 2) 2xx 但响应体是 JSON 错误信封(后端 HTTP 200 + {code,message}) + const ct = getHeader(res.headers, 'content-type') + if (ct.includes('application/json')) { + return fail(await blobToMessage(res.data)) + } + + // 3) 2xx 且非 JSON,assertRealFile 已在步骤 0 挡过 HTML / 截断,这里直接落盘 + + // 4) 真实文件流,触发下载(originalUrl 用于跨设备兜底) + const name = extractFilename(getHeader(res.headers, 'content-disposition'), fallbackName) + triggerDownload(res.data, name, url) + if (onSuccess) onSuccess(name) + return true + } catch (err) { + // 同 URL 正在下载被去重忽略(__DUP__)——已在前置给出 info 提示,这里静默返回 + if (err && err.message === '__DUP__') return false + // 网络/超时/中止等 axios 异常绝不能向上抛(调用方多无 try/catch), + // 否则会表现为「点击下载毫无反应」。统一转成可见错误提示。 + return fail(err?.message || '下载失败,请检查网络后重试') + } finally { + loading.close() + } +} + +/** + * 取回文件 blob(用于预览),与 safeDownload 共用同一套内容真实性守卫。 + * @returns {Promise<{ok:boolean, blob?:Blob, message?:string, ignored?:boolean}>} + */ +export async function fetchBlobGuarded(url, opts = {}) { + // 同 safeDownload:1M 带宽下放大超时,避免预览大文件超时 + const { timeout = 0, onProgress } = opts + try { + const res = await guardedGet(url, timeout, onProgress) + + // 🔴 内容真实性校验(2026-08-12 标签泄漏修复,与 safeDownload 共用 assertRealFile): + // 必须放在「状态码 / JSON」判断之前——nginx 404 / SPA 回退会返回 status 404 + text/html + // 错误页,若先走状态码分支会把 …nginx… 原标签塞进 message,再被 ElMessage 裸漏到弹窗。 + // 命中(响应体是 HTML 错误页 / 与 X-File-Size 哨兵不符)一律返回干净提示,绝不泄漏标签。 + const contentErr = await assertRealFile(res) + if (contentErr) return { ok: false, message: contentErr } + + if (res.status < 200 || res.status >= 300) { + return { ok: false, message: (await blobToMessage(res.data)) || `下载失败 (${res.status})` } + } + const ct = getHeader(res.headers, 'content-type') + if (ct.includes('application/json')) { + return { ok: false, message: await blobToMessage(res.data) } + } + return { ok: true, blob: res.data } + } catch (err) { + // 同 URL 正在下载被去重忽略(__DUP__)——标记为 ignored,调用方可据此静默跳过 + if (err && err.message === '__DUP__') return { ok: false, ignored: true } + return { ok: false, message: err?.message || '预览加载失败,请检查网络后重试' } + } +} diff --git a/src/utils/jwt.js b/src/utils/jwt.js new file mode 100644 index 0000000..225a750 --- /dev/null +++ b/src/utils/jwt.js @@ -0,0 +1,45 @@ +// ============================================================ +// JWT 前端工具 +// 仅用于「过期预判」——不校验签名(签名由后端 jwt.verify 负责)。 +// 解决:客户端鉴权原本只判断 token 是否存在,导致过期 token 通过路由守卫, +// 在资料页等首次真实请求时才 401 并弹出"登录已过期"。 +// ============================================================ + +/** + * 解码 JWT payload(不验证签名) + * @param {string} token + * @returns {object|null} + */ +export function decodeJwtPayload(token) { + if (!token || typeof token !== 'string') return null + const parts = token.split('.') + if (parts.length !== 3) return null + try { + let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const pad = base64.length % 4 + if (pad) base64 += '='.repeat(4 - pad) + const json = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ) + return JSON.parse(json) + } catch (e) { + return null + } +} + +/** + * 判断 token 是否已过期(允许 clockSkew 秒的时钟偏差,避免临界点抖动) + * - 无法解析 / 没有 exp 字段 → 视为已失效(保守策略) + * @param {string} token + * @param {number} clockSkew 秒 + * @returns {boolean} + */ +export function isTokenExpired(token, clockSkew = 30) { + const payload = decodeJwtPayload(token) + if (!payload || typeof payload.exp !== 'number') return true + const now = Math.floor(Date.now() / 1000) + return now >= payload.exp - clockSkew +} diff --git a/src/utils/testSms.js b/src/utils/testSms.js new file mode 100644 index 0000000..26a216f --- /dev/null +++ b/src/utils/testSms.js @@ -0,0 +1,37 @@ +/** + * testSms.js - 测试环境短信验证码弹窗 + * + * 设计原则(与生产版本差异最小化): + * - 后端仅在【测试环境】(APP_ENV=test 或缺省)的短信接口响应体中回传 smsCode 字段; + * 生产环境真实下发短信、响应体不含 smsCode。 + * - 因此本弹窗由「响应是否含 smsCode」自动触发:测试环境弹窗显示验证码, + * 生产环境无 smsCode、不弹窗、走真实短信。无需前端单独判断运行模式。 + * + * 用法: + * import { showTestSmsCode } from '@/utils/testSms' + * if (result?.smsCode) showTestSmsCode(result.smsCode, '账号激活') + */ +import { ElMessageBox } from 'element-plus' + +/** + * 弹出测试环境验证码提示框 + * @param {string} code - 6 位验证码 + * @param {string} title - 标题(如 '账号激活' / '忘记密码') + */ +export function showTestSmsCode(code, title = '短信验证码') { + ElMessageBox.alert( + `
+

【测试环境】未真实发送短信,以下为本次验证码

+

${code}

+

验证码 5 分钟内有效;生产环境将通过短信发送至手机

+
`, + title, + { + dangerouslyUseHTMLString: true, + confirmButtonText: '我已记录', + type: 'warning', + center: true, + customClass: 'test-sms-code-dialog', + } + ) +} diff --git a/src/utils/upload.js b/src/utils/upload.js new file mode 100644 index 0000000..4f67ae2 --- /dev/null +++ b/src/utils/upload.js @@ -0,0 +1,164 @@ +import SparkMD5 from 'spark-md5' + +// ==================== 文件上传工具 ==================== + +// 默认分片大小:2MB +const DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024 + +/** + * 将文件切分为多个分片 + * @param {File} file - 要切分的文件对象 + * @param {number} chunkSize - 每个分片的大小(字节),默认 2MB + * @returns {Array<{ chunk: Blob, index: number, total: number }>} 分片数组 + */ +export function chunkFile(file, chunkSize = DEFAULT_CHUNK_SIZE) { + const chunks = [] + const total = Math.ceil(file.size / chunkSize) + + for (let i = 0; i < total; i++) { + const start = i * chunkSize + const end = Math.min(start + chunkSize, file.size) + chunks.push({ + // 文件分片 + chunk: file.slice(start, end), + // 分片索引(从 0 开始) + index: i, + // 总分片数 + total, + }) + } + + return chunks +} + +/** + * 使用 spark-md5 计算文件的 MD5 哈希值 + * 用于文件完整性校验和断点续传标识 + * @param {File|Blob} file - 要计算哈希的文件 + * @returns {Promise} MD5 哈希值(十六进制字符串) + */ +export function calculateMd5(file) { + return new Promise((resolve, reject) => { + const chunkSize = DEFAULT_CHUNK_SIZE + const chunks = Math.ceil(file.size / chunkSize) + const spark = new SparkMD5.ArrayBuffer() + const reader = new FileReader() + let currentChunk = 0 + + // 文件读取完成 + reader.onload = function (e) { + spark.append(e.target.result) // 追加到 spark 计算 + currentChunk++ + + if (currentChunk < chunks) { + // 还有更多分片,继续读取下一片 + loadNext() + } else { + // 所有分片处理完毕,生成最终哈希 + const md5 = spark.end() + resolve(md5) + } + } + + // 文件读取错误 + reader.onerror = function () { + reject(new Error('文件读取失败,无法计算 MD5')) + } + + // 加载下一个分片 + function loadNext() { + const start = currentChunk * chunkSize + const end = Math.min(start + chunkSize, file.size) + reader.readAsArrayBuffer(file.slice(start, end)) + } + + // 开始读取第一个分片 + loadNext() + }) +} + +/** + * 带重试机制的上传函数 + * 使用指数退避策略,在网络不稳定时自动重试 + * @param {function} uploadFn - 上传函数,应返回 Promise + * @param {number} maxRetries - 最大重试次数,默认 3 + * @param {number} baseDelay - 基础重试延迟(毫秒),默认 1000 + * @returns {Promise} 上传函数的返回值 + */ +export async function uploadWithRetry(uploadFn, maxRetries = 3, baseDelay = 1000) { + let lastError = null + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // 首次 attempt=0 不算重试 + const result = await uploadFn() + return result + } catch (error) { + lastError = error + + // 已达到最大重试次数,不再重试 + if (attempt >= maxRetries) { + break + } + + // 指数退避延迟:delay = baseDelay * 2^attempt + // 第1次重试: 1 秒, 第2次: 2 秒, 第3次: 4 秒 + const delay = baseDelay * Math.pow(2, attempt) + console.warn( + `上传第 ${attempt + 1} 次重试,等待 ${delay / 1000} 秒...`, + error + ) + await sleep(delay) + } + } + + // 所有重试都失败 + throw lastError || new Error('上传失败:已达最大重试次数') +} + +/** + * 格式化文件大小为可读字符串 + * @param {number} bytes - 文件大小(字节) + * @param {number} decimals - 保留小数位数,默认 2 + * @returns {string} 格式化后的文件大小字符串,如 "1.50 MB" + */ +export function formatFileSize(bytes, decimals = 2) { + if (bytes === 0) return '0 B' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i] +} + +// ==================== 内部辅助 ==================== + +/** + * 延时函数 + * @param {number} ms - 延时毫秒数 + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// ==================== 图片 URL 规范化 ==================== + +const BACKEND_BASE = '' + +/** + * 将相对路径的图片 URL 补全为完整 URL + * 例如 /public/carousel/xxx.jpg → http://127.0.0.1:7001/public/carousel/xxx.jpg + * @param {string} url - 图片 URL + * @returns {string} 完整 URL + */ +export function normalizeImageUrl(url) { + if (!url) return '' + // 已经是完整 URL 或 data URL,直接返回 + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { + return url + } + // 相对路径补全 + return BACKEND_BASE + url +} diff --git a/src/utils/uploadCombo.js b/src/utils/uploadCombo.js new file mode 100644 index 0000000..5b54e03 --- /dev/null +++ b/src/utils/uploadCombo.js @@ -0,0 +1,89 @@ +/** + * uploadCombo.js - 作品上传「组合 / 互斥 / 自动覆盖」纯逻辑 + * + * 该模块不依赖 Vue / Element Plus,仅接收普通对象 { uid?, name, size }, + * 因此可被 Node 直接单测(见 scripts/verify_upload_combo.mjs)。 + * + * 规则(对应测试反馈 Issue ⑪⑫⑬): + * - zip 须单独上传,与任何其他文件互斥,且相互自动覆盖(zip 独占)。 + * - pdf 与 docx 相互覆盖,word 类(pdf/doc/docx)至多保留一个。 + * - 允许的终态组合:{zip} / {word} / {ppt} / {word,ppt} + * (即「zip」「pdf+ppt」「docx+ppt」三种完整组合 + 单文件退化态)。 + * - 新加入的文件覆盖与之冲突的旧文件(last-write-wins)。 + */ + +export const ALLOWED = ['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.zip'] +export const ZIP_MAX = 200 * 1024 * 1024 +export const OTHER_MAX = 100 * 1024 * 1024 + +export function fileExt(name) { + return (String(name).split('.').pop() || '').toLowerCase() +} + +// 文件大类:zip / word(pdf|doc|docx) / ppt / other +export function categoryOf(name) { + const e = '.' + fileExt(name) + if (e === '.zip') return 'zip' + if (['.doc', '.docx', '.pdf'].includes(e)) return 'word' + if (['.ppt', '.pptx'].includes(e)) return 'ppt' + return 'other' +} + +export function categoryLabel(c) { + return ({ zip: 'ZIP 压缩包', word: 'Word/PDF 文档', ppt: 'PPT 演示稿', other: '未知格式' })[c] || c +} + +export function categoryTagType(c) { + return ({ zip: 'warning', word: 'primary', ppt: 'success', other: 'info' })[c] || 'info' +} + +// 单文件基础校验:扩展名 + 大小,返回错误信息或 null +export function validateSingleFile(name, size) { + const e = '.' + fileExt(name) + if (!ALLOWED.includes(e)) return `不支持的格式:${name}` + const max = e === '.zip' ? ZIP_MAX : OTHER_MAX + if (size > max) return `${name} 超过大小限制(${e === '.zip' ? '200MB' : '100MB'})` + return null +} + +// 终态合法性检查:zip 独占;word 至多一个(pdf 与 docx 互斥) +export function finalComboCheck(files) { + const cats = files.map(f => categoryOf(f.name)) + const zipCount = cats.filter(c => c === 'zip').length + const wordCount = cats.filter(c => c === 'word').length + if (zipCount > 0 && files.length > 1) { + return { ok: false, error: 'zip 压缩包须单独上传,不能与其他文件共存' } + } + if (wordCount > 1) { + return { ok: false, error: 'Word/PDF 文档只能保留一个(pdf 与 docx 相互覆盖)' } + } + return { ok: true, error: null } +} + +/** + * 选择文件时自动覆盖/互斥校验:新加入的文件覆盖冲突的旧文件。 + * @param {Array<{uid:string|number,name:string,size:number}>} files 当前待上传列表 + * @param {{uid:string|number,name:string,size:number}} added 本次新加入的文件 + * @returns {{list:Array, dropped:Array, addedCat:string, isNewValid:boolean, valid:boolean, error:?string}} + */ +export function reconcileFiles(files, added) { + let list = files.filter(f => validateSingleFile(f.name, f.size) === null) + const isNewValid = validateSingleFile(added.name, added.size) === null + const addedCat = categoryOf(added.name) + let dropped = [] + if (isNewValid) { + if (addedCat === 'zip') { + // zip 覆盖所有非 zip + dropped = list.filter(f => f.uid !== added.uid && categoryOf(f.name) !== 'zip') + } else if (addedCat === 'word') { + // word 覆盖其他 word(pdf 与 docx 互斥);若已有 zip,新 word 覆盖 zip + dropped = list.filter(f => f.uid !== added.uid && (categoryOf(f.name) === 'word' || categoryOf(f.name) === 'zip')) + } else if (addedCat === 'ppt') { + // ppt 不与 word 互斥;但若已存在 zip,ppt 覆盖 zip(zip 独占) + dropped = list.filter(f => f.uid !== added.uid && categoryOf(f.name) === 'zip') + } + list = list.filter(f => !dropped.includes(f)) + } + const check = finalComboCheck(list) + return { list, dropped, addedCat, isNewValid, valid: check.ok, error: check.error } +} diff --git a/src/views/Activate.vue b/src/views/Activate.vue new file mode 100644 index 0000000..fe931ba --- /dev/null +++ b/src/views/Activate.vue @@ -0,0 +1,676 @@ + + + + + diff --git a/src/views/Admin.vue b/src/views/Admin.vue new file mode 100644 index 0000000..d18368f --- /dev/null +++ b/src/views/Admin.vue @@ -0,0 +1,2900 @@ + + + + + diff --git a/src/views/AtSimulator.vue b/src/views/AtSimulator.vue new file mode 100644 index 0000000..5fe9b01 --- /dev/null +++ b/src/views/AtSimulator.vue @@ -0,0 +1,806 @@ + + + + + \ No newline at end of file diff --git a/src/views/Competition.vue b/src/views/Competition.vue new file mode 100644 index 0000000..f72fcc8 --- /dev/null +++ b/src/views/Competition.vue @@ -0,0 +1,1458 @@ + + + + + \ No newline at end of file diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue new file mode 100644 index 0000000..343f6cd --- /dev/null +++ b/src/views/Dashboard.vue @@ -0,0 +1,490 @@ + + + + + \ No newline at end of file diff --git a/src/views/ForgotPassword.vue b/src/views/ForgotPassword.vue new file mode 100644 index 0000000..832bf28 --- /dev/null +++ b/src/views/ForgotPassword.vue @@ -0,0 +1,617 @@ + + + + + diff --git a/src/views/Home.vue b/src/views/Home.vue new file mode 100644 index 0000000..2649c17 --- /dev/null +++ b/src/views/Home.vue @@ -0,0 +1,965 @@ + + + + + \ No newline at end of file diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..0c48e17 --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,440 @@ + + + + + \ No newline at end of file diff --git a/src/views/Materials.vue b/src/views/Materials.vue new file mode 100644 index 0000000..6ba22c2 --- /dev/null +++ b/src/views/Materials.vue @@ -0,0 +1,361 @@ + + + + + \ No newline at end of file diff --git a/src/views/MockExam.vue b/src/views/MockExam.vue new file mode 100644 index 0000000..32b5aa4 --- /dev/null +++ b/src/views/MockExam.vue @@ -0,0 +1,826 @@ + + + + + \ No newline at end of file diff --git a/src/views/NewsDetail.vue b/src/views/NewsDetail.vue new file mode 100644 index 0000000..2fe3dbd --- /dev/null +++ b/src/views/NewsDetail.vue @@ -0,0 +1,366 @@ + + + + + \ No newline at end of file diff --git a/src/views/NmeaParser.vue b/src/views/NmeaParser.vue new file mode 100644 index 0000000..e53b798 --- /dev/null +++ b/src/views/NmeaParser.vue @@ -0,0 +1,661 @@ + + + + + \ No newline at end of file diff --git a/src/views/NotFound.vue b/src/views/NotFound.vue new file mode 100644 index 0000000..2565d42 --- /dev/null +++ b/src/views/NotFound.vue @@ -0,0 +1,50 @@ + + + + + + \ No newline at end of file diff --git a/src/views/Score.vue b/src/views/Score.vue new file mode 100644 index 0000000..a6a5af9 --- /dev/null +++ b/src/views/Score.vue @@ -0,0 +1,234 @@ + + + + + \ No newline at end of file diff --git a/src/views/Upload.vue b/src/views/Upload.vue new file mode 100644 index 0000000..e93c83d --- /dev/null +++ b/src/views/Upload.vue @@ -0,0 +1,735 @@ + + + + + + + + + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ab38c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..aba1c34 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,60 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + // 修复 Vite 内部 viteTransformMiddleware 调用 decodeURI 时遇到格式错误的 URL + // 导致 "URI malformed" 错误的问题。在 Vite 中间件之前拦截并安全处理。 + { + name: 'fix-uri-malformed', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url) { + try { + // 预检 URL 是否可被 decodeURI 安全解码 + decodeURI(req.url) + } catch (_e) { + // URL 包含无效的百分号编码序列,直接返回 400,避免进入 Vite 内部中间件 + console.warn(`[fix-uri-malformed] 拦截到格式错误的请求 URL,已返回 400`) + res.statusCode = 400 + res.end() + return + } + } + next() + }) + }, + }, + ], + + // 开发服务器配置 + server: { + host: '0.0.0.0', + port: 5173, + // 禁用 HMR 错误覆盖层,避免非致命错误(如 URI malformed)遮挡页面 + hmr: { + overlay: false, + }, + // 代理配置:将 /api 和 /public 请求转发到后端服务 + proxy: { + '/api': { + target: 'http://127.0.0.1:7001', + changeOrigin: true, + }, + '/public': { + target: 'http://127.0.0.1:7001', + changeOrigin: true, + }, + }, + }, + + // 路径别名配置 + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})