Compare commits
18 Commits
e8f0195b61
...
4e7493a809
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e7493a809 | |||
| 3f8316da47 | |||
| ecf8d39f03 | |||
| 0416ac3360 | |||
| 86f3b6c28c | |||
| ea350fb2e0 | |||
| 042e0a8e1f | |||
| cb672b621a | |||
| 2ca82d0da3 | |||
| e5579f034e | |||
| c470e7922e | |||
| fb9c48aaac | |||
| 397356f141 | |||
| 37d2641e49 | |||
| bdfa4a9618 | |||
| cddf91d13a | |||
| c2551b64e8 | |||
| 1bfe007149 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Vite 在 dev server 因配置变化重启时会生成该临时打包文件;
|
||||
# 若进程异常退出(例如被沙箱 safe-delete 钩子中断)会残留,不应进入版本库。
|
||||
vite.config.js.timestamp-*.mjs
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<!-- SortableJS 已改为 npm 依赖本地打包(见 src/main.js),不再从公网 CDN 引入:
|
||||
内网/离线部署时 CDN 不可达会让 window.Sortable 缺失,
|
||||
而 Admin.vue 中的 `if (!window.Sortable) return` 会静默吞掉新闻/轮播的拖拽排序功能。 -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
379
package-lock.json
generated
379
package-lock.json
generated
@@ -14,12 +14,14 @@
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"pinia": "^2.1.0",
|
||||
"sortablejs": "^1.15.0",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
@@ -474,10 +476,53 @@
|
||||
"version": "0.2.11",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
@@ -869,6 +914,19 @@
|
||||
"snabbdom": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"license": "MIT",
|
||||
@@ -908,6 +966,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"license": "MIT",
|
||||
@@ -923,6 +997,13 @@
|
||||
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.3.1.tgz",
|
||||
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"license": "MIT"
|
||||
@@ -1210,6 +1291,13 @@
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.1.tgz",
|
||||
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ext": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz",
|
||||
@@ -1218,6 +1306,24 @@
|
||||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"funding": [
|
||||
@@ -1388,6 +1494,24 @@
|
||||
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"license": "MIT"
|
||||
@@ -1484,6 +1608,38 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"license": "MIT"
|
||||
@@ -1518,10 +1674,44 @@
|
||||
"version": "1.2.0",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "2.3.1",
|
||||
"license": "MIT",
|
||||
@@ -1542,6 +1732,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.3.tgz",
|
||||
"integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.3.1",
|
||||
"exsolve": "^1.1.1",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"funding": [
|
||||
@@ -1600,6 +1802,37 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.0",
|
||||
"dev": true,
|
||||
@@ -1680,6 +1913,12 @@
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.0.tgz",
|
||||
"integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -1701,6 +1940,23 @@
|
||||
"resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/type": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
|
||||
@@ -1719,6 +1975,122 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.4.0.tgz",
|
||||
"integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.7",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@farmfe/core": "*",
|
||||
"@rsbuild/core": "*",
|
||||
"@rspack/core": "*",
|
||||
"bun-types-no-globals": "*",
|
||||
"esbuild": "*",
|
||||
"rolldown": "*",
|
||||
"rollup": "*",
|
||||
"unloader": "*",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@farmfe/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rsbuild/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"bun-types-no-globals": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"unloader": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
|
||||
"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "32.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
|
||||
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.2.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.2",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
@@ -1837,6 +2209,13 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"pinia": "^2.1.0",
|
||||
"sortablejs": "^1.15.0",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
123
src/api/admin.js
123
src/api/admin.js
@@ -347,4 +347,127 @@ export function resetTestData() {
|
||||
return request.post('/admin/reset-test-data')
|
||||
}
|
||||
|
||||
// ==================== 综合大题管理(中职/高职) ====================
|
||||
|
||||
/**
|
||||
* 获取综合大题题库(按组别)
|
||||
*
|
||||
* 仅中职/高职启用;本科/教师组调用返回 400(未启用综合大题)。
|
||||
* @param {string} group - 组别标识(zhongzhi / gaozhi)
|
||||
* @returns {Promise} {
|
||||
* list: [{ id, questionUid, group, title, description,
|
||||
* subPrompt1, subPrompt2, subPrompt3,
|
||||
* scorePerSub, totalScore, sortOrder, status, createdAt, updatedAt }],
|
||||
* total, scorePerSub, subCount, maxChars
|
||||
* }
|
||||
*/
|
||||
export function getBigQuestions(group) {
|
||||
return request.get(`/admin/big-questions/${group}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增大题
|
||||
*
|
||||
* 题干 description 为富文本 HTML(支持表格);三小问提示 subPrompt1~3 与题干同存一行。
|
||||
* @param {string} group - 组别标识
|
||||
* @param {object} data - { title, description, subPrompt1, subPrompt2, subPrompt3, scorePerSub, sortOrder, status }
|
||||
* @returns {Promise} { id, questionUid }
|
||||
*/
|
||||
export function createBigQuestion(group, data) {
|
||||
return request.post(`/admin/big-questions/${group}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新大题
|
||||
*
|
||||
* 注意:已被考生抽取的题目仍可修改,但考生端展示的是抽题时刻的题干快照,
|
||||
* 不会受此修改影响(防纠纷设计)。
|
||||
* @param {string} group - 组别标识
|
||||
* @param {number|string} id - 大题 ID
|
||||
* @param {object} data - 同 createBigQuestion
|
||||
* @returns {Promise} { id }
|
||||
*/
|
||||
export function updateBigQuestion(group, id, data) {
|
||||
return request.put(`/admin/big-questions/${group}/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除大题
|
||||
*
|
||||
* 已被考生抽取的题目服务端拒绝删除(返回 409),避免作答记录悬空。
|
||||
* @param {string} group - 组别标识
|
||||
* @param {number|string} id - 大题 ID
|
||||
* @returns {Promise} { deleted }
|
||||
*/
|
||||
export function deleteBigQuestion(group, id) {
|
||||
return request.delete(`/admin/big-questions/${group}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取大题作答与查证列表(覆盖该组全部考生,含未抽题者)
|
||||
*
|
||||
* 每行含考生信息、抽题快照、三小问答案、已答数量、三小问得分、大题总分与提交时间,
|
||||
* 供管理员核对考生实际抽到的题目与作答内容,避免考生纠纷。
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} {
|
||||
* list: [{ contestantId, aggregationUid, name, idNumber, phone, teamId,
|
||||
* drawn, bigQuestionUid, questionSnapshot,
|
||||
* answer1, answer2, answer3, answeredCount,
|
||||
* score1, score2, score3, bigScore, wordScore, submitTime }],
|
||||
* total, drawnCount
|
||||
* }
|
||||
*/
|
||||
export function getBigAnswers(group) {
|
||||
return request.get(`/admin/big-answers/${group}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入三小问得分(小题分)
|
||||
*
|
||||
* 服务端逐问校验 0 ~ scorePerSub 区间,累加后经唯一评分写路径
|
||||
* service.admin.updateScores 落 contestants.word_score(中职/高职总分 = exam_score + word_score)。
|
||||
* @param {string} group - 组别标识
|
||||
* @param {number|string} uid - 考生聚合 ID(aggregation_uid)
|
||||
* @param {{score_1: number, score_2: number, score_3: number}} scores
|
||||
* @returns {Promise} { total, perSubScore }
|
||||
*/
|
||||
export function setBigAnswerScores(group, uid, scores) {
|
||||
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
|
||||
}
|
||||
|
||||
// ==================== 成绩复核 ====================
|
||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
|
||||
/**
|
||||
* 检索考生(姓名 / 身份证号 / 电话 任一,模糊匹配)
|
||||
*
|
||||
* 不传 group 则全组别检索 —— 复核时通常只拿到一个身份证号,不应强迫先猜组别。
|
||||
* @param {string} keyword - 检索词
|
||||
* @param {string} [group] - 可选:限定组别
|
||||
* @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, objectiveApplicable }], total, truncated }
|
||||
*/
|
||||
export function reviewSearch(keyword, group) {
|
||||
const params = { keyword }
|
||||
if (group) params.group = group
|
||||
return request.get('/admin/review/search', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 考生全卷明细(复核)
|
||||
*
|
||||
* 返回客观题逐题(题干 / 选项 / 考生答案 / **正确答案** / 对错 / 得分 / 满分)
|
||||
* + 综合大题(题干快照 + 三小问作答与得分)+ 本科/教师组作品文件清单。
|
||||
* ⚠️ 含正确答案,仅管理员可用。
|
||||
* @param {string} group - 组别标识(须与考生实际组别一致)
|
||||
* @param {number|string} contestantId - 考生 ID(contestants.id)
|
||||
* @returns {Promise} { name, idNumber, phone, group, objectiveApplicable, scorePublished,
|
||||
* objective: [{ seq, questionType, questionUid, question, options, correctAnswer, maxScore,
|
||||
* answer, answered, isCorrect, score, answerMatches, questionMissing }],
|
||||
* summary: { answered, total, correctCount, autoScore, wordScore, totalScore },
|
||||
* bigQuestion, works, warnings, matchedKeys, questionOrderFixed }
|
||||
*/
|
||||
export function getContestantScore(group, contestantId) {
|
||||
return request.get(`/admin/review/score/${group}/${contestantId}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,13 +31,14 @@ export function activate(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 参赛者登录(身份证号 + 密码)
|
||||
* @param {string} idNumber - 身份证号
|
||||
* 参赛者登录(手机号 + 密码)
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} password - 密码
|
||||
* @param {object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器逐次错误提示)
|
||||
* @returns {Promise} { token, user }
|
||||
*/
|
||||
export function login(phone, password) {
|
||||
return request.post('/auth/login', { phone, password })
|
||||
export function login(phone, password, config) {
|
||||
return request.post('/auth/login', { phone, password }, config)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,15 +22,6 @@ 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 - 组别标识
|
||||
@@ -41,25 +32,41 @@ export function submitAnswers(group, answers) {
|
||||
return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true })
|
||||
}
|
||||
|
||||
// 注:原 getWordTemplate / uploadWord / deleteWord 三个封装已随「中职/高职取消 Word
|
||||
// 上传题」一并移除(2026-09-21)。后端对应路由亦已对中职/高职封堵;本科/教师组的
|
||||
// 文档上传走分片通道 @/api/upload,不经此处。
|
||||
|
||||
// ==================== 综合大题(中职/高职) ====================
|
||||
|
||||
/**
|
||||
* 上传 Word 答题文件
|
||||
* @param {string} group - 组别标识
|
||||
* @param {FormData} formData - 包含 Word 文件的 FormData
|
||||
* @returns {Promise}
|
||||
* 获取综合大题(首次调用即抽题并落库快照,后续调用复用同一题)
|
||||
*
|
||||
* 服务端保证「一考生一行、一题一快照」:抽题幂等(reused=true 表示复用),
|
||||
* 题干以抽题时刻的快照为准(管理员后续改题干不影响已抽考生,防纠纷)。
|
||||
* 响应体不含任何分数字段(成绩发布门控)。
|
||||
* @param {string} group - 组别标识(仅 zhongzhi / gaozhi)
|
||||
* @returns {Promise} {
|
||||
* question: { big_question_uid, title, description, sub_prompts, score_per_sub, total_score, snapshot_at },
|
||||
* answers: { answer_1, answer_2, answer_3 },
|
||||
* submitTime, maxChars, subCount, reused
|
||||
* }
|
||||
*/
|
||||
export function uploadWord(group, formData) {
|
||||
return request.post(`/exam/${group}/upload-word`, formData, {
|
||||
timeout: 60000, // 上传超时 60 秒
|
||||
})
|
||||
export function getBigQuestion(group) {
|
||||
// silent:本接口的失败(如 4004 该组暂无大题)由答题区自行展示 + 提供重试,
|
||||
// 避免与页面其他提示重复弹窗。
|
||||
return request.get(`/exam/${group}/big-question`, { silent: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已上传的 Word 答题文件(中职/高职组)
|
||||
* 保存综合大题三小问答案(整卷覆盖保存)
|
||||
*
|
||||
* 超长由前端 maxlength 与服务端双重拦截;服务端超限返回 400 且整批拒绝(不静默截断)。
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise}
|
||||
* @param {{answer_1: string, answer_2: string, answer_3: string, forceSubmit?: boolean}} data
|
||||
* @returns {Promise} { filled, subCount, maxChars, savedAt }
|
||||
*/
|
||||
export function deleteWord(group) {
|
||||
return request.delete(`/exam/${group}/word`, { timeout: 60000 })
|
||||
export function saveBigAnswer(group, data) {
|
||||
return request.post(`/exam/${group}/big-answer`, data, { silent: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -175,6 +175,16 @@ request.interceptors.response.use(
|
||||
return res
|
||||
},
|
||||
async (error) => {
|
||||
// silent 模式(2026-09-21 新增):调用方可在请求 config 上传 silent: true
|
||||
// 来抑制**逐次**错误提示。供自带走重试的调用方(登录 / 交卷 / 逐题保存)使用,
|
||||
// 避免重试期间连弹多条相同报错、让考生误以为系统崩了。
|
||||
// ⛔ 不传 silent 的既有调用方行为**完全不变**(silent 为 undefined → falsy)。
|
||||
// 注:401 分支的提示由 forceLogout 负责(token 真失效必须让用户看到),不受此影响。
|
||||
const silent = !!(error.config && error.config.silent)
|
||||
const notifyError = (msg) => {
|
||||
if (!silent) ElMessage.error(msg)
|
||||
}
|
||||
|
||||
// 处理 HTTP 状态码错误
|
||||
if (error.response) {
|
||||
const { status } = error.response
|
||||
@@ -240,26 +250,26 @@ request.interceptors.response.use(
|
||||
}
|
||||
|
||||
case 403:
|
||||
ElMessage.error('您没有权限执行此操作')
|
||||
notifyError('您没有权限执行此操作')
|
||||
break
|
||||
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
notifyError('请求的资源不存在')
|
||||
break
|
||||
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误,请稍后重试')
|
||||
notifyError('服务器内部错误,请稍后重试')
|
||||
break
|
||||
|
||||
default:
|
||||
ElMessage.error(error.response.data?.message || `请求失败 (${status})`)
|
||||
notifyError(error.response.data?.message || `请求失败 (${status})`)
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
// 请求超时
|
||||
ElMessage.error('请求超时,请检查网络连接')
|
||||
notifyError('请求超时,请检查网络连接')
|
||||
} else {
|
||||
// 网络错误或其他错误
|
||||
ElMessage.error('网络连接异常,请检查网络')
|
||||
notifyError('网络连接异常,请检查网络')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -2,50 +2,12 @@ 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 },
|
||||
})
|
||||
}
|
||||
// 🔴 分片上传 API(uploadChunk / mergeChunks / getChunkStatus)已于 2026-09-29 · R43 删除:
|
||||
// 全仓 0 调用点(唯一调用方 Upload.vue 在 B2 清理孤儿路由时一并删除)⇒ 死代码。
|
||||
// 后端路由 /api/upload/:group/{chunk,merge,chunk-status} 与对应控制器方法**刻意保留**
|
||||
// —— 本次只清前端死代码,不动服务端契约(避免影响外部调用方与后续复用)。
|
||||
// ⚠️ 注意「在用的」作品上传走下方 uploadWork()(单次流式 POST /api/upload/:group),
|
||||
// 不是分片通道 —— 别被"分片上传"这个更完善的名字误导。
|
||||
|
||||
/**
|
||||
* 获取上传状态
|
||||
|
||||
91
src/auto-imports.d.ts
vendored
91
src/auto-imports.d.ts
vendored
@@ -1,91 +0,0 @@
|
||||
/* 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')
|
||||
}
|
||||
20
src/components.d.ts
vendored
20
src/components.d.ts
vendored
@@ -12,39 +12,51 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
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']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
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']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElResult: typeof import('element-plus/es')['ElResult']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
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']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -29,6 +29,9 @@ const props = defineProps({
|
||||
height: { type: String, default: '320px' },
|
||||
placeholder: { type: String, default: '请输入内容…' },
|
||||
mode: { type: String, default: 'default' }, // 'default' | 'simple'
|
||||
// 是否开放表格菜单。综合大题题干需要富文本表格,故显式开启;
|
||||
// 默认 false 保持资讯正文等既有调用方的工具栏不变(行为兼容)。
|
||||
enableTable: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -49,14 +52,15 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const toolbarConfig = {
|
||||
const toolbarConfig = computed(() => ({
|
||||
excludeKeys: [
|
||||
'group-video', // 轮播正文不需要视频
|
||||
'insertTable',
|
||||
'group-video', // 题干/正文不需要视频
|
||||
// 未开启表格时隐藏插入表格入口;开启后除去该项,表格相关菜单恢复默认可见
|
||||
...(props.enableTable ? [] : ['insertTable']),
|
||||
'codeBlock',
|
||||
'fullScreen',
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder,
|
||||
|
||||
34
src/main.js
34
src/main.js
@@ -1,13 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
// Element Plus 按需引入(见 vite.config.js 的 Components 插件):
|
||||
// 不再全量注册组件,也不再引入 element-plus/dist/index.css。
|
||||
// 此处只保留「语言包」与「全局 locale 设置」两项职责。
|
||||
import { provideGlobalConfig } from 'element-plus'
|
||||
// Element Plus 中文语言包
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
// Element Plus 样式
|
||||
import 'element-plus/dist/index.css'
|
||||
// 🔴 函数式组件不经过模板编译,按需插件(静态扫描)无法为它们注入样式 ⇒ 必须显式引入。
|
||||
// 这四类在项目中的调用次数:ElMessage 147 / ElMessageBox 23 / ElNotification 4 / ElLoading 2。
|
||||
// 漏掉任一条的后果是「弹窗能弹出但无样式」(裸文本),构建不报错,只有真浏览器可见。
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import 'element-plus/es/components/notification/style/css'
|
||||
import 'element-plus/es/components/loading/style/css'
|
||||
// 全局样式(含 Element Plus 主题色变量,防止 getThemeColors 解构 undefined 报错)
|
||||
import './styles/global.css'
|
||||
|
||||
// SortableJS:新闻/轮播的拖拽排序依赖 window.Sortable(见 Admin.vue initNewsSortable/initCarouselSortable)。
|
||||
// 原先由 index.html 从 cdn.jsdelivr.net 加载,内网/离线环境取不到时会静默失效
|
||||
// (Admin.vue 内的 `if (!window.Sortable) return` 守卫不会报错也不提示)。
|
||||
// 改为本地依赖并由 Vite 打包进产物,彻底消除对外网 CDN 的运行时依赖。
|
||||
import Sortable from 'sortablejs'
|
||||
window.Sortable = Sortable
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
// 主动 token 续期(防活跃用户因 token 过期被强制踢回登录页)
|
||||
@@ -27,10 +42,15 @@ app.use(router)
|
||||
// 启动主动 token 续期:活跃用户不会因 token 过期被强制踢出登录
|
||||
initProactiveTokenRefresh()
|
||||
|
||||
// 注册 Element Plus(使用中文语言包)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
// 设置 Element Plus 全局配置(中文语言包)。
|
||||
// 与原 app.use(ElementPlus, { locale: zhCn }) 语义等价:Element Plus 的 install
|
||||
// 在注册完组件后做的正是 provideGlobalConfig(options, app, true)
|
||||
// (见 node_modules/element-plus/es/make-installer.mjs)。
|
||||
// 第三个参数 global=true 是关键 —— 它把 locale 写入 globalConfig,
|
||||
// 使 ElMessageBox / ElPagination 等「脱离组件树的函数式 API」也能取到中文文案;
|
||||
// 少了它,确认框按钮会退回英文 OK / Cancel。
|
||||
// 组件注册职责已移交 vite 的按需插件,此处不再注册任何组件。
|
||||
provideGlobalConfig({ locale: zhCn }, app, true)
|
||||
|
||||
// 全局指令 v-guard:为按钮/可点击元素提供「防重入 + 节流」,
|
||||
// 防止狂点触发大量并发请求打爆连接池(2026-08-10 账号列表超时根因)。
|
||||
|
||||
@@ -16,7 +16,6 @@ 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')
|
||||
|
||||
@@ -106,14 +105,6 @@ const routes = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
|
||||
// 作品上传页
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'Upload',
|
||||
component: Upload,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
|
||||
// 资料下载页
|
||||
{
|
||||
path: '/materials',
|
||||
|
||||
@@ -1,8 +1,153 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as authApi from '@/api/auth'
|
||||
|
||||
// ==================== 登录自动重试策略(2026-09-21 并发压测第二轮结论) ====================
|
||||
//
|
||||
// 背景:后端把 /api/auth/login 纳入并发闸门(middleware/concurrency_guard.js 的
|
||||
// LOGIN_PATTERNS),过载时由「TCP 层静默 RST —— 客户端只拿到 WinError 10061,
|
||||
// 考生完全不知道自己是"登不上"还是"要多点几次"」变为
|
||||
// 「HTTP 429 + Retry-After —— 明确、可识别、可定时重试」。
|
||||
// 客户端**必须**配套重试才能收敛,否则只是把「静默失败」换成了「明确失败」。
|
||||
//
|
||||
// 与交卷重试(stores/exam.js:66-68、504-529)**同源同策略**:
|
||||
// 6 次尝试,退避 1s→2s→4s→8s→15s→15s(封顶),总计约 45s。
|
||||
// 此处刻意**不**抽公共工具:exam.js 的交卷链路刚经 46 项专项验证 + 两轮压测复测
|
||||
// (已稳定),为「不影响其他正常功能」而不触碰它。⚠️ 两处策略常量若有变动须同步。
|
||||
//
|
||||
// ==================== R8(2026-09-21):让 Retry-After 真正可达 ====================
|
||||
//
|
||||
// 问题:后端在 L118/L137/L157 一直发 `Retry-After: 2`,但**此前前端从不读它**
|
||||
// ⇒ 该响应头在生产上是**纯装饰**(只在压测探针里生效,因为探针读它)。
|
||||
// 结果:被拒后仍按 1s 的首次退避立刻回来 → 撞墙(实测登录侧请求 +14%、重试 +28%)。
|
||||
//
|
||||
// 修法:**服务端建议**与**指数退避**取较大者,再叠加抖动。三条决策依据:
|
||||
// ① 为何取 max 而非直接用 Retry-After:
|
||||
// 服务端只知道"当前多忙",不知道客户端已重试几次。若纯用它,重试后期
|
||||
// (退避已到 8s/15s)反而会被**压回** 2s ⇒ 比改动前更频繁地撞墙;
|
||||
// 纯用退避则忽略服务端的明确信号。取 max 兼顾两者。
|
||||
// ② 为何要抖动(jitter):800 人几乎同刻被拒 ⇒ 若都等同一时长,会形成
|
||||
// 「同步重试潮」在同一时刻再次撞墙(thundering herd)。±20% 抖动把重试打散。
|
||||
// ③ 为何要给服务端值封顶:后端异常(如误配一个很大的值)不得把考生**锁死**在
|
||||
// 等待里 —— 超过 LOGIN_RETRY_SERVER_CAP_MS 一律按上限处理。
|
||||
//
|
||||
// ⚠️ 已知的**非同源**(有意为之,非遗漏):`stores/exam.js` 的交卷重试与
|
||||
// `file_io_guard` 的上传重试**仍不读** `Retry-After`。理由:交卷链路实测在
|
||||
// 5 档 429 下均 100% 收敛(最终 HTTP 200),而它是考试关键路径、回归风险高于收益。
|
||||
// ⛔ 若将来要统一,必须走独立排期 + 专项回归,不要"顺手"改。
|
||||
//
|
||||
// 重试安全性(为何不会把考生锁死):
|
||||
// ① 登录请求无副作用(仅校验口令 + 签发 JWT),被拒/超时的请求从未到达业务逻辑;
|
||||
// ② 后端闸门在 **loginRateLimit 之前**返回 429(全局中间件先于 router 级),
|
||||
// 故 429 不会被计入「登录失败次数」→ 重试**不会**把考生自己打到 15 分钟账号锁;
|
||||
// ③ 密码错误(3005)/未激活(3004)/手机号不在名单(3003) 等**业务判定**不在重试范围,
|
||||
// 立即抛出(经拦截器转为无 response 字段的裸 Error,据此与网络层错误区分)。
|
||||
const LOGIN_MAX_ATTEMPTS = 6
|
||||
const LOGIN_RETRY_BASE_MS = 1000
|
||||
const LOGIN_RETRY_CAP_MS = 15000
|
||||
// 服务端 Retry-After 的采纳上限(防误配把考生锁死);见文件头 R8 第 ③ 条
|
||||
const LOGIN_RETRY_SERVER_CAP_MS = 15000
|
||||
// 抖动幅度(±20%):把"同时被拒"的考生重试时刻打散,避免同步重试潮
|
||||
const LOGIN_RETRY_JITTER = 0.2
|
||||
|
||||
/**
|
||||
* 判断登录失败是否可安全重试
|
||||
* - 有 HTTP 响应:429(闸门背压,带 Retry-After)或 5xx(服务端瞬时故障)→ 可重试;
|
||||
* 其余 4xx(语义已明确)→ 不重试。
|
||||
* - 无 HTTP 响应:axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端,可重试。
|
||||
* - 拦截器为「业务码非 0」生成的裸 Error(无 response/code/request/config)→ 不重试。
|
||||
*/
|
||||
function isRetriableLoginError(err) {
|
||||
if (!err) return false
|
||||
const status = err.response && err.response.status
|
||||
if (status) return status === 429 || (status >= 500 && status < 600)
|
||||
return !!(err.isAxiosError || err.code || err.request || err.config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应头 `Retry-After` 为毫秒(无效/缺失返回 0)
|
||||
*
|
||||
* 只接受 **delay-seconds**(整数秒)形式 —— 后端 concurrency_guard.js 发的即此形式。
|
||||
* HTTP 规范另允许 HTTP-date 形式,本系统不使用;无法解析的值一律返回 0
|
||||
* (安全回退到纯指数退避:绝不因一个坏 header 让重试变快或卡死)。
|
||||
*
|
||||
* ⚠️ axios 的 headers 可能是普通对象或 AxiosHeaders 实例,两种形态都要兼容。
|
||||
*/
|
||||
function parseRetryAfterMs(err) {
|
||||
const headers = err && err.response && err.response.headers
|
||||
if (!headers) return 0
|
||||
let raw
|
||||
if (typeof headers.get === 'function') {
|
||||
raw = headers.get('retry-after')
|
||||
}
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
raw = headers['retry-after']
|
||||
}
|
||||
if (raw === undefined || raw === null || raw === '') return 0
|
||||
const sec = Number(raw)
|
||||
if (!Number.isFinite(sec) || sec <= 0) return 0
|
||||
return Math.min(sec * 1000, LOGIN_RETRY_SERVER_CAP_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算第 attempt 次失败后的重试等待(毫秒)
|
||||
*
|
||||
* = max(指数退避, 服务端 Retry-After) × (1 ± LOGIN_RETRY_JITTER)
|
||||
* 设计依据见文件头「R8」三条(为何取 max / 为何抖动 / 为何给服务端值封顶)。
|
||||
*/
|
||||
function computeLoginRetryDelayMs(err, attempt) {
|
||||
const backoff = Math.min(
|
||||
LOGIN_RETRY_CAP_MS,
|
||||
LOGIN_RETRY_BASE_MS * Math.pow(2, attempt - 1)
|
||||
)
|
||||
const base = Math.max(backoff, parseRetryAfterMs(err))
|
||||
const jittered =
|
||||
base * (1 - LOGIN_RETRY_JITTER + Math.random() * LOGIN_RETRY_JITTER * 2)
|
||||
return Math.round(jittered)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把最终失败的错误包装成考生能看懂的文案(由 Login.vue 的统一 catch 弹出)。
|
||||
* 业务错误(如「密码错误」「账号未激活」)原样返回,保留后端文案。
|
||||
*/
|
||||
function friendlyLoginError(err) {
|
||||
const status = (err && err.response && err.response.status) || 0
|
||||
if (status === 429) {
|
||||
return new Error('当前登录人数过多,服务器繁忙,请稍后重试(若持续失败请举手联系监考老师)')
|
||||
}
|
||||
if (status >= 500 && status < 600) {
|
||||
return new Error('服务器暂时不可用,请稍后重试')
|
||||
}
|
||||
if (!status && err && (err.isAxiosError || err.code || err.request)) {
|
||||
return new Error('网络连接异常,请检查网络后重试')
|
||||
}
|
||||
return err // 业务错误:保留后端文案(密码错误 / 账号未激活 / 登录过于频繁…)
|
||||
}
|
||||
|
||||
// 重试期间的常驻提示(持续显示,成功后关闭;避免连弹多条相同报错)
|
||||
let _loginRetryTip = null
|
||||
function showLoginRetryTip() {
|
||||
if (_loginRetryTip) return
|
||||
_loginRetryTip = ElMessage({
|
||||
message: '服务器繁忙,正在自动重试登录,请勿刷新页面(最多约 45 秒)',
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
showClose: true,
|
||||
})
|
||||
}
|
||||
function closeLoginRetryTip() {
|
||||
if (_loginRetryTip) {
|
||||
try {
|
||||
_loginRetryTip.close()
|
||||
} catch (e) {
|
||||
/* 忽略:提示已被用户关闭 */
|
||||
}
|
||||
_loginRetryTip = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 认证状态管理 ====================
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -66,16 +211,41 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// ==================== 操作方法(Actions) ====================
|
||||
|
||||
/**
|
||||
* 参赛者登录(身份证号 + 密码)
|
||||
* @param {string} idNumber - 身份证号
|
||||
* 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」)
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise<object>} 用户信息
|
||||
*/
|
||||
async function login(phone, password) {
|
||||
const result = await authApi.login(phone, password)
|
||||
// 后端应返回 { token, user }
|
||||
persistAuth(result.token, result.user)
|
||||
return result.user
|
||||
let lastErr = null
|
||||
for (let attempt = 1; attempt <= LOGIN_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// silent:抑制拦截器的逐次错误提示,改由本函数统一提示(避免连弹多条)
|
||||
const result = await authApi.login(phone, password, { silent: true })
|
||||
closeLoginRetryTip()
|
||||
// 后端应返回 { token, user } —— 仅在真正拿到结果后才落盘
|
||||
persistAuth(result.token, result.user)
|
||||
return result.user
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
if (!isRetriableLoginError(err) || attempt >= LOGIN_MAX_ATTEMPTS) {
|
||||
closeLoginRetryTip()
|
||||
throw friendlyLoginError(err)
|
||||
}
|
||||
showLoginRetryTip()
|
||||
// max(指数退避, 服务端 Retry-After) + 抖动 —— 见文件头 R8
|
||||
const delay = computeLoginRetryDelayMs(err, attempt)
|
||||
const serverHint = parseRetryAfterMs(err)
|
||||
console.warn(
|
||||
`[auth] 登录第 ${attempt} 次失败(可重试),${delay}ms 后重试` +
|
||||
`(服务端 Retry-After=${serverHint ? `${serverHint}ms` : '未提供'}):`,
|
||||
err && err.message
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
closeLoginRetryTip()
|
||||
throw friendlyLoginError(lastErr)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,6 @@ 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 标识(防止返回重新进入)
|
||||
@@ -19,6 +17,172 @@ let timerInterval = null
|
||||
let resyncInterval = null
|
||||
let finalCheckInFlight = false
|
||||
|
||||
// ==================== 综合大题(中职/高职,替代原 Word 上传题) ====================
|
||||
|
||||
/** 启用综合大题的组别(与后端 service.bigQuestion.BIG_QUESTION_GROUPS 保持一致) */
|
||||
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
|
||||
/** 答题字数上限默认值(后端返回 maxChars 后以其为准) */
|
||||
const DEFAULT_MAX_ANSWER_CHARS = 2000
|
||||
|
||||
/** 抽题快照(题干以抽题时刻为准,管理员后续改题干不影响已抽考生) */
|
||||
const bigQuestion = ref(null)
|
||||
/** 三小问答案 { answer_1, answer_2, answer_3 } */
|
||||
const bigAnswers = ref({ answer_1: '', answer_2: '', answer_3: '' })
|
||||
const bigSubmitTime = ref(null)
|
||||
const bigMaxChars = ref(DEFAULT_MAX_ANSWER_CHARS)
|
||||
const bigSubCount = ref(3)
|
||||
const bigReused = ref(false)
|
||||
const bigLoading = ref(false)
|
||||
const bigLoaded = ref(false)
|
||||
const bigError = ref('')
|
||||
const bigSaving = ref(false)
|
||||
let bigSaveTimer = null
|
||||
|
||||
// ==================== 答案本地草稿与落库重试(防丢答案) ====================
|
||||
//
|
||||
// 背景(2026-09-21 并发压测结论):
|
||||
// 原 submitAnswer 在保存失败时只 console.warn(静默),答案仅存在于内存 answers 中。
|
||||
// 一旦刷新页面 / 断电 / 浏览器崩溃,整场作答永久丢失;若交卷同时失败
|
||||
// (400 并发下约 20% 考生的连接在 TCP accept 层即被拒,请求从未到达后端),
|
||||
// 考生将无法恢复任何内容。这对竞赛是不可接受的。
|
||||
//
|
||||
// 本段提供两层兜底:
|
||||
// ① 待重试队列(pendingSaves):逐题保存失败的答案记入队列,指数退避重试直到成功;
|
||||
// ② 本地草稿:把作答写入 localStorage(按「组别 + 账号」隔离),重进考试时与
|
||||
// 服务端答案合并,补齐「服务端缺失」的作答。
|
||||
//
|
||||
// 设计原则(重要):
|
||||
// - 草稿**只补空位、绝不覆盖服务端已有值** —— 服务端是权威。最坏情况是给考生补回
|
||||
// 一道他已答过的题(他可见并可修改),而不会用旧内容改写新作答;
|
||||
// - 场次隔离用 status.startTime(管理员中途延长 endTime 属同一场次,不应使草稿作废);
|
||||
// - 草稿仅在**交卷成功**后清除 —— 任何失败路径都必须保留,否则兜底形同虚设。
|
||||
|
||||
const DRAFT_VERSION = 1
|
||||
const DRAFT_KEY_PREFIX = 'exam_draft::'
|
||||
const DRAFT_WRITE_DEBOUNCE_MS = 200
|
||||
const SAVE_RETRY_MAX = 6 // 逐题保存重试次数上限(退避 2s→4s→…→30s 封顶)
|
||||
const SAVE_RETRY_BASE_MS = 2000
|
||||
const SAVE_RETRY_CAP_MS = 30000
|
||||
const SUBMIT_MAX_ATTEMPTS = 6 // 交卷自动重试次数上限(退避 1s→2s→…→15s 封顶)
|
||||
const SUBMIT_RETRY_BASE_MS = 1000
|
||||
const SUBMIT_RETRY_CAP_MS = 15000
|
||||
|
||||
/** 草稿键:按「组别 + 账号」隔离(机房共用电脑时不同考生互不覆盖) */
|
||||
function draftKeyOf(group) {
|
||||
let uid = 'anon'
|
||||
try {
|
||||
const u = JSON.parse(localStorage.getItem('user') || 'null')
|
||||
uid = String((u && (u.id || u.uid || u.phone || u.username)) || 'anon')
|
||||
} catch (e) { /* 解析失败退化为 anon,仍受组别隔离 */ }
|
||||
return `${DRAFT_KEY_PREFIX}${group}::${uid}`
|
||||
}
|
||||
|
||||
/** 逐题保存失败、待重试的作答 { questionUid: answer } */
|
||||
const pendingSaves = ref({})
|
||||
let draftTimer = null
|
||||
let saveRetryTimer = null
|
||||
let saveRetryAttempt = 0
|
||||
/** 考试场次标识:status.startTime(延长 endTime 属同一场次,不应使草稿作废) */
|
||||
let draftWindowStart = null
|
||||
|
||||
/** 立即落盘草稿(同步写 localStorage) */
|
||||
function writeDraftNow() {
|
||||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||
const g = currentGroup.value
|
||||
if (!g) return
|
||||
try {
|
||||
localStorage.setItem(draftKeyOf(g), JSON.stringify({
|
||||
v: DRAFT_VERSION,
|
||||
group: g,
|
||||
windowStart: draftWindowStart,
|
||||
updatedAt: Date.now(),
|
||||
answers: { ...answers.value },
|
||||
big: BIG_QUESTION_GROUPS.includes(g) ? { ...bigAnswers.value } : null,
|
||||
pending: Object.keys(pendingSaves.value),
|
||||
}))
|
||||
} catch (e) {
|
||||
// 隐私模式 / 配额写满:草稿只是兜底能力,写入失败绝不能打断考生答题
|
||||
console.warn('[exam] 本地草稿写入失败(不影响答题与交卷)', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 防抖落盘(逐字符输入时避免写放大) */
|
||||
function scheduleDraftWrite() {
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
draftTimer = setTimeout(() => { draftTimer = null; writeDraftNow() }, DRAFT_WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 读取草稿(不校验场次,由调用方决定是否采用) */
|
||||
function readDraft(group) {
|
||||
const g = group || currentGroup.value
|
||||
if (!g) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKeyOf(g))
|
||||
if (!raw) return null
|
||||
const d = JSON.parse(raw)
|
||||
if (!d || d.v !== DRAFT_VERSION) return null
|
||||
return d
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除草稿(仅在交卷成功后调用) */
|
||||
function clearDraft(group) {
|
||||
try { localStorage.removeItem(draftKeyOf(group || currentGroup.value)) } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
/** 是否有尚未同步到服务端的作答(供 UI 提示使用) */
|
||||
function hasUnsynced() {
|
||||
return Object.keys(pendingSaves.value).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 把待重试队列里的答案一次性推给服务端
|
||||
* @returns {Promise<boolean>} 是否全部成功(队列为空视为成功)
|
||||
*/
|
||||
async function flushPendingSaves() {
|
||||
const entries = Object.entries(pendingSaves.value)
|
||||
if (!entries.length) return true
|
||||
const batch = entries.map(([uid, ans]) => ({
|
||||
question_uid: uid,
|
||||
question_type: typeOfUid(uid),
|
||||
answer_data: ans,
|
||||
}))
|
||||
try {
|
||||
await examApi.submitAnswers(currentGroup.value, batch)
|
||||
pendingSaves.value = {}
|
||||
writeDraftNow()
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避调度待重试队列(已有在途定时器时不重复排程) */
|
||||
function scheduleSaveRetry() {
|
||||
if (saveRetryTimer) return
|
||||
if (saveRetryAttempt >= SAVE_RETRY_MAX) return
|
||||
const delay = Math.min(SAVE_RETRY_CAP_MS, SAVE_RETRY_BASE_MS * Math.pow(2, saveRetryAttempt))
|
||||
saveRetryAttempt++
|
||||
saveRetryTimer = setTimeout(async () => {
|
||||
saveRetryTimer = null
|
||||
const ok = await flushPendingSaves()
|
||||
if (ok) {
|
||||
saveRetryAttempt = 0
|
||||
} else {
|
||||
scheduleSaveRetry()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 停止草稿与重试相关定时器(退出考试页时调用;不清草稿,草稿只在交卷成功后清) */
|
||||
function stopDraftTimers() {
|
||||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||
if (saveRetryTimer) { clearTimeout(saveRetryTimer); saveRetryTimer = null }
|
||||
saveRetryAttempt = 0
|
||||
}
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
@@ -41,6 +205,58 @@ const progress = computed(() => {
|
||||
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
|
||||
const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge'))
|
||||
|
||||
/** 当前组别是否启用综合大题(中职/高职启用,本科/教师不启用) */
|
||||
const bigQuestionEnabled = computed(() => BIG_QUESTION_GROUPS.includes(currentGroup.value))
|
||||
|
||||
/** 三小问提示文案数组(从快照取,长度与 subCount 对齐) */
|
||||
const bigSubPrompts = computed(() => {
|
||||
const prompts = (bigQuestion.value && bigQuestion.value.sub_prompts) || []
|
||||
const out = []
|
||||
for (let i = 0; i < bigSubCount.value; i++) {
|
||||
out.push(prompts[i] || '')
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** 每小题分值(快照优先,回退 10 分) */
|
||||
const bigScorePerSub = computed(() => {
|
||||
const v = bigQuestion.value && bigQuestion.value.score_per_sub
|
||||
return Number(v) > 0 ? Number(v) : 10
|
||||
})
|
||||
|
||||
/** 大题总分(快照优先,回退「每小题分值 × 小问数」) */
|
||||
const bigTotalScore = computed(() => {
|
||||
const v = bigQuestion.value && bigQuestion.value.total_score
|
||||
if (Number(v) > 0) return Number(v)
|
||||
return bigScorePerSub.value * bigSubCount.value
|
||||
})
|
||||
|
||||
/** 已作答(去空白后非空)的小问数量 */
|
||||
const bigAnsweredCount = computed(() => {
|
||||
let n = 0
|
||||
for (let i = 1; i <= bigSubCount.value; i++) {
|
||||
const v = bigAnswers.value[`answer_${i}`]
|
||||
if (v != null && String(v).trim().length > 0) n++
|
||||
}
|
||||
return n
|
||||
})
|
||||
|
||||
/** 三小问是否全部作答 */
|
||||
const bigAllAnswered = computed(() => bigAnsweredCount.value >= bigSubCount.value)
|
||||
|
||||
/** 每个小问的字数统计 { answer_1: 12, ... }(用于右下角实时字数) */
|
||||
const bigCharCounts = computed(() => {
|
||||
const out = {}
|
||||
for (let i = 1; i <= bigSubCount.value; i++) {
|
||||
const v = bigAnswers.value[`answer_${i}`]
|
||||
out[`answer_${i}`] = v == null ? 0 : String(v).length
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** 综合大题完成状态(供进度/答题卡复用) */
|
||||
const bigCompleted = computed(() => bigAllAnswered.value)
|
||||
|
||||
// ==================== 内部函数 ====================
|
||||
|
||||
function parseOptions(q) {
|
||||
@@ -129,6 +345,7 @@ function stopTimer() {
|
||||
|
||||
async function startExam(group) {
|
||||
stopExam()
|
||||
draftWindowStart = null // 重置场次标识,避免把上一场的草稿归到本场
|
||||
|
||||
// 生成唯一的考试 session ID
|
||||
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
||||
@@ -146,6 +363,8 @@ async function startExam(group) {
|
||||
if (stateData.countdown !== undefined) {
|
||||
countdown.value = stateData.countdown
|
||||
}
|
||||
// 记录本场考试标识(隔离不同场次的本地草稿;见「答案本地草稿」段)
|
||||
draftWindowStart = stateData.startTime || null
|
||||
}
|
||||
|
||||
if (questionList.status === 'fulfilled' && questionList.value) {
|
||||
@@ -158,7 +377,10 @@ async function startExam(group) {
|
||||
totalQuestions.value = responseData.count || rawQuestions.length
|
||||
}
|
||||
|
||||
wordTemplate.value = { group }
|
||||
// 中职/高职:进入考试即抽取综合大题(后端幂等,重进复用同一题与快照)
|
||||
if (BIG_QUESTION_GROUPS.includes(group)) {
|
||||
await loadBigQuestion(group)
|
||||
}
|
||||
|
||||
examStarted.value = true
|
||||
startTimer()
|
||||
@@ -190,6 +412,7 @@ async function startUploadCountdown(group) {
|
||||
|
||||
function stopExam() {
|
||||
stopTimer()
|
||||
stopDraftTimers()
|
||||
examStarted.value = false
|
||||
sessionStorage.removeItem('exam_session')
|
||||
}
|
||||
@@ -208,8 +431,9 @@ function typeOfUid(questionUid) {
|
||||
}
|
||||
|
||||
async function submitAnswer(questionId, answer) {
|
||||
// 本地先保存答案
|
||||
// 本地先保存答案,并立即写入本地草稿(先落本地、再谈服务端)
|
||||
answers.value = { ...answers.value, [questionId]: answer }
|
||||
scheduleDraftWrite()
|
||||
|
||||
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
|
||||
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
|
||||
@@ -217,13 +441,53 @@ async function submitAnswer(questionId, answer) {
|
||||
await examApi.submitAnswers(currentGroup.value, [
|
||||
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
|
||||
])
|
||||
// 落库成功:从待重试队列中移除该题
|
||||
if (pendingSaves.value[questionId] !== undefined) {
|
||||
const next = { ...pendingSaves.value }
|
||||
delete next[questionId]
|
||||
pendingSaves.value = next
|
||||
writeDraftNow()
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不弹窗打扰用户答题
|
||||
console.warn('提交答案失败', e)
|
||||
// 🔴 不再静默丢弃(原为仅 console.warn):记入待重试队列并落本地草稿,
|
||||
// 由指数退避重试直到成功。仍不弹窗打扰答题(失败多为瞬时网络抖动),
|
||||
// 但交卷前会强制补推一次,且答题页可据 hasUnsynced 提示「未同步 N 题」。
|
||||
pendingSaves.value = { ...pendingSaves.value, [questionId]: answer }
|
||||
writeDraftNow()
|
||||
scheduleSaveRetry()
|
||||
console.warn('提交答案失败,已记入重试队列并保留本地草稿', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断交卷失败是否可安全重试
|
||||
*
|
||||
* 可重试(请求**未到达业务逻辑**,或服务端瞬时过载):
|
||||
* - 无 HTTP 响应但属 axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端;
|
||||
* - HTTP 429(并发闸门主动背压,响应带 Retry-After);HTTP 5xx(服务端瞬时故障)。
|
||||
* 不可重试(业务已作出明确判定,重试无意义):
|
||||
* - 拦截器为「业务码非 0」生成的裸 Error(4001 考试已结束 / 4004 未答满 /
|
||||
* 4005 团队锁等)。它没有 axios 的 response、code、config 字段,
|
||||
* 据此与网络层错误区分 —— 否则会把「未答满」误判为可重试并空转 6 次。
|
||||
*/
|
||||
function isRetriableSubmitError(err) {
|
||||
if (!err) return false
|
||||
const status = err.response && err.response.status
|
||||
if (status) return status === 429 || (status >= 500 && status < 600)
|
||||
return !!(err.isAxiosError || err.code || err.request || err.config)
|
||||
}
|
||||
|
||||
async function submitAll(additionalData = {}) {
|
||||
// 交卷前先把未确认落库的答案补推一次(不因失败而阻塞:submit-all 本身会带上
|
||||
// 内存中的全部答案做一次全量落库,是更强的兜底)
|
||||
try { await flushPendingSaves() } catch (e) { /* 忽略,交由后续全量提交 */ }
|
||||
|
||||
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
|
||||
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
|
||||
if (bigQuestionEnabled.value) {
|
||||
await flushBigAnswers({ force: additionalData.forceSubmit === true, silent: true })
|
||||
}
|
||||
|
||||
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
|
||||
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
|
||||
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
|
||||
@@ -236,19 +500,177 @@ async function submitAll(additionalData = {}) {
|
||||
totalQuestions: totalQuestions.value,
|
||||
...additionalData,
|
||||
}
|
||||
const result = await examApi.submitAll(currentGroup.value, data)
|
||||
stopExam()
|
||||
return result
|
||||
|
||||
// 🔴 交卷自动重试(2026-09-21 并发压测结论)
|
||||
//
|
||||
// 缺陷:400 并发同刻交卷时约 20% 考生的交卷请求在 TCP accept 层就被拒
|
||||
// (Windows 在 accept 队列满时回 RST,客户端拿到 10061),请求**从未到达后端**;
|
||||
// 原实现仅提示「请手动点击提交全部」,而手动提交不带 forceSubmit、
|
||||
// 窗口关闭后必被 4001 拒 —— 这批考生会彻底交不上卷(答案在库里却 submit_time 为空)。
|
||||
//
|
||||
// 重试安全性论证:被拒请求从未到达业务逻辑(无副作用);后端落库为 upsert(幂等),
|
||||
// submit_time 由 ensureExamSubmission 保证只写一次(findOrCreate + `!submit_time` 保护),
|
||||
// 因此重试不会产生重复行、不会覆盖首次提交时间。
|
||||
let lastErr = null
|
||||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const result = await examApi.submitAll(currentGroup.value, data)
|
||||
stopExam()
|
||||
clearDraft() // 仅在交卷成功后清除草稿;任何失败路径都必须保留兜底数据
|
||||
return result
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
if (!isRetriableSubmitError(err) || attempt >= SUBMIT_MAX_ATTEMPTS) throw err
|
||||
const delay = Math.min(SUBMIT_RETRY_CAP_MS, SUBMIT_RETRY_BASE_MS * Math.pow(2, attempt - 1))
|
||||
console.warn(`[exam] 交卷第 ${attempt} 次失败(可重试),${delay}ms 后重试:`, err && err.message)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
// ==================== 综合大题方法 ====================
|
||||
|
||||
/**
|
||||
* 拉取(首次即抽取并落库快照)当前考生的综合大题与已存答案
|
||||
* @param {string} [group] - 组别,缺省用 currentGroup
|
||||
* @returns {Promise<boolean>} 是否成功装载(失败时错误信息在 bigError)
|
||||
*/
|
||||
async function loadBigQuestion(group) {
|
||||
const g = group || currentGroup.value
|
||||
if (!g || !BIG_QUESTION_GROUPS.includes(g)) return false
|
||||
|
||||
bigLoading.value = true
|
||||
bigError.value = ''
|
||||
try {
|
||||
const data = await examApi.getBigQuestion(g)
|
||||
bigQuestion.value = data.question || null
|
||||
bigAnswers.value = {
|
||||
answer_1: (data.answers && data.answers.answer_1) || '',
|
||||
answer_2: (data.answers && data.answers.answer_2) || '',
|
||||
answer_3: (data.answers && data.answers.answer_3) || '',
|
||||
}
|
||||
bigSubmitTime.value = data.submitTime || null
|
||||
if (Number(data.maxChars) > 0) bigMaxChars.value = Number(data.maxChars)
|
||||
if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount)
|
||||
bigReused.value = data.reused === true
|
||||
bigLoaded.value = !!bigQuestion.value
|
||||
|
||||
// 用本地草稿补齐服务端缺失的小问(只补空位,绝不覆盖服务端已有内容)
|
||||
const d = readDraft(g)
|
||||
if (bigLoaded.value && d && d.big) {
|
||||
const mergedBig = { ...bigAnswers.value }
|
||||
let filled = 0
|
||||
for (const k of ['answer_1', 'answer_2', 'answer_3']) {
|
||||
const local = d.big[k]
|
||||
if ((!mergedBig[k] || !String(mergedBig[k]).trim()) && local && String(local).trim()) {
|
||||
mergedBig[k] = local
|
||||
filled++
|
||||
}
|
||||
}
|
||||
if (filled > 0) bigAnswers.value = mergedBig
|
||||
}
|
||||
return bigLoaded.value
|
||||
} catch (e) {
|
||||
// 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生
|
||||
bigError.value = (e && e.message) || '综合大题加载失败'
|
||||
bigQuestion.value = null
|
||||
bigLoaded.value = false
|
||||
return false
|
||||
} finally {
|
||||
bigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新某小问答案(本地即时生效 + 防抖自动保存)
|
||||
* @param {number} index - 小问序号 1~3
|
||||
* @param {string} value - 文本
|
||||
*/
|
||||
function setBigAnswer(index, value) {
|
||||
const key = `answer_${index}`
|
||||
bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) }
|
||||
scheduleDraftWrite() // 先落本地草稿:即便随后的防抖保存失败也不会丢
|
||||
scheduleBigSave()
|
||||
}
|
||||
|
||||
/** 防抖自动保存(800ms 静默合并,避免逐字符打接口) */
|
||||
function scheduleBigSave() {
|
||||
if (bigSaveTimer) clearTimeout(bigSaveTimer)
|
||||
bigSaveTimer = setTimeout(() => {
|
||||
bigSaveTimer = null
|
||||
flushBigAnswers({ silent: true })
|
||||
}, 800)
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即把三小问答案落库
|
||||
* @param {{force?: boolean, silent?: boolean}} [opts]
|
||||
* force - 时间窗口已关闭时仍允许落库(仅自动交卷通道使用)
|
||||
* silent - 失败不弹窗(由调用方决定提示方式)
|
||||
* @returns {Promise<{ok: boolean, message?: string}>}
|
||||
*/
|
||||
async function flushBigAnswers(opts = {}) {
|
||||
if (!bigQuestionEnabled.value) return { ok: true }
|
||||
if (!bigLoaded.value) return { ok: true } // 未抽到题,无需保存
|
||||
|
||||
if (bigSaveTimer) {
|
||||
clearTimeout(bigSaveTimer)
|
||||
bigSaveTimer = null
|
||||
}
|
||||
|
||||
const payload = {
|
||||
answer_1: bigAnswers.value.answer_1 || '',
|
||||
answer_2: bigAnswers.value.answer_2 || '',
|
||||
answer_3: bigAnswers.value.answer_3 || '',
|
||||
}
|
||||
if (opts.force === true) payload.forceSubmit = true
|
||||
|
||||
bigSaving.value = true
|
||||
try {
|
||||
await examApi.saveBigAnswer(currentGroup.value, payload)
|
||||
bigError.value = ''
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
// 超长(服务端 400)等失败:保留本地内容并回传错误,由页面提示,避免静默丢答案
|
||||
const message = (e && e.message) || '综合大题答案保存失败'
|
||||
bigError.value = message
|
||||
if (!opts.silent) {
|
||||
console.warn('综合大题答案保存失败:', message)
|
||||
}
|
||||
return { ok: false, message }
|
||||
} finally {
|
||||
bigSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空综合大题本地状态(含未落库的防抖定时器) */
|
||||
function resetBigQuestion() {
|
||||
if (bigSaveTimer) {
|
||||
clearTimeout(bigSaveTimer)
|
||||
bigSaveTimer = null
|
||||
}
|
||||
bigQuestion.value = null
|
||||
bigAnswers.value = { answer_1: '', answer_2: '', answer_3: '' }
|
||||
bigSubmitTime.value = null
|
||||
bigMaxChars.value = DEFAULT_MAX_ANSWER_CHARS
|
||||
bigSubCount.value = 3
|
||||
bigReused.value = false
|
||||
bigLoading.value = false
|
||||
bigLoaded.value = false
|
||||
bigError.value = ''
|
||||
bigSaving.value = false
|
||||
}
|
||||
|
||||
function resetExam() {
|
||||
stopTimer()
|
||||
stopDraftTimers()
|
||||
resetBigQuestion()
|
||||
questions.value = []
|
||||
answers.value = {}
|
||||
countdown.value = 0
|
||||
examStarted.value = false
|
||||
wordTemplate.value = null
|
||||
wordUploaded.value = false
|
||||
totalQuestions.value = 0
|
||||
currentGroup.value = ''
|
||||
examSessionId = ''
|
||||
sessionStorage.removeItem('exam_session')
|
||||
@@ -260,9 +682,54 @@ function isSessionValid() {
|
||||
return saved === examSessionId && examSessionId !== ''
|
||||
}
|
||||
|
||||
/** 恢复已有答案(考试重新进入时使用) */
|
||||
/** 恢复已有答案(服务端为基线;考试重新进入时使用) */
|
||||
function restoreAnswers(restoredAnswers) {
|
||||
answers.value = { ...restoredAnswers }
|
||||
answers.value = { ...(restoredAnswers || {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用本地草稿补齐服务端缺失的作答(考试重新进入时调用,且应在 restoreAnswers 之后)
|
||||
*
|
||||
* 合并规则(重要):**只补空位,绝不覆盖服务端已有值**。服务端是权威,
|
||||
* 草稿仅承载「逐题保存失败因而未能上报」的作答。
|
||||
* 场次隔离:草稿的 windowStart 与当前场次(startTime)不一致时判为上一场考试并丢弃。
|
||||
*
|
||||
* @returns {{merged:number, stale:boolean}} merged = 从草稿补回的题数
|
||||
*/
|
||||
function restoreDraft() {
|
||||
const g = currentGroup.value
|
||||
if (!g) return { merged: 0, stale: false }
|
||||
const d = readDraft(g)
|
||||
if (!d) return { merged: 0, stale: false }
|
||||
|
||||
// 不同场次(startTime 不同)→ 草稿属上一场考试,清除以免污染本场
|
||||
if (draftWindowStart && d.windowStart && d.windowStart !== draftWindowStart) {
|
||||
clearDraft(g)
|
||||
return { merged: 0, stale: true }
|
||||
}
|
||||
|
||||
const next = { ...answers.value }
|
||||
let merged = 0
|
||||
for (const [uid, ans] of Object.entries(d.answers || {})) {
|
||||
if (ans === undefined || ans === null || String(ans).length === 0) continue
|
||||
const cur = next[uid]
|
||||
// 服务端已有作答 → 不覆盖(草稿可能是更早的内容)
|
||||
if (cur !== undefined && cur !== null && String(cur).length > 0) continue
|
||||
next[uid] = ans
|
||||
merged++
|
||||
}
|
||||
answers.value = next
|
||||
|
||||
// 草稿里标记为「未成功落库」的题重新排入重试队列
|
||||
const pending = { ...pendingSaves.value }
|
||||
for (const uid of (d.pending || [])) {
|
||||
if (next[uid] !== undefined) pending[uid] = next[uid]
|
||||
}
|
||||
if (Object.keys(pending).length) {
|
||||
pendingSaves.value = pending
|
||||
scheduleSaveRetry()
|
||||
}
|
||||
return { merged, stale: false }
|
||||
}
|
||||
|
||||
export function useExamStore() {
|
||||
@@ -273,15 +740,33 @@ export function useExamStore() {
|
||||
answers,
|
||||
countdown,
|
||||
examStarted,
|
||||
wordTemplate,
|
||||
wordUploaded,
|
||||
totalQuestions,
|
||||
// 综合大题状态
|
||||
bigQuestion,
|
||||
bigAnswers,
|
||||
bigSubmitTime,
|
||||
bigMaxChars,
|
||||
bigSubCount,
|
||||
bigReused,
|
||||
bigLoading,
|
||||
bigLoaded,
|
||||
bigError,
|
||||
bigSaving,
|
||||
// 计算属性
|
||||
countdownDisplay,
|
||||
isTimeUp,
|
||||
progress,
|
||||
choiceQuestions,
|
||||
judgeQuestions,
|
||||
// 综合大题计算属性
|
||||
bigQuestionEnabled,
|
||||
bigSubPrompts,
|
||||
bigScorePerSub,
|
||||
bigTotalScore,
|
||||
bigAnsweredCount,
|
||||
bigAllAnswered,
|
||||
bigCharCounts,
|
||||
bigCompleted,
|
||||
// 方法
|
||||
startExam,
|
||||
startUploadCountdown,
|
||||
@@ -291,5 +776,15 @@ export function useExamStore() {
|
||||
resetExam,
|
||||
isSessionValid,
|
||||
restoreAnswers,
|
||||
// 本地草稿与落库重试(防丢答案)
|
||||
restoreDraft,
|
||||
flushPendingSaves,
|
||||
hasUnsynced,
|
||||
pendingSaves,
|
||||
// 综合大题方法
|
||||
loadBigQuestion,
|
||||
setBigAnswer,
|
||||
flushBigAnswers,
|
||||
resetBigQuestion,
|
||||
})
|
||||
}
|
||||
228
src/utils/richtext.js
Normal file
228
src/utils/richtext.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 富文本渲染安全工具(零依赖白名单消毒)
|
||||
*
|
||||
* 背景:综合大题题干由管理员通过 wangEditor 录入富文本(含表格),考生端以 v-html 渲染。
|
||||
* v-html 直接插入未经处理的 HTML 等价于把渲染权交给内容作者,因此必须做纵深防御:
|
||||
* 服务端已做基础危险片段拦截,前端在渲染前再做一次白名单收敛,保证即使内容被
|
||||
* 绕过入口写入(历史数据 / 直接改库 / 其他写入通道),渲染侧依然无法执行脚本。
|
||||
*
|
||||
* 白名单策略(默认拒绝):
|
||||
* - 标签:仅保留排版类标签(段落 / 标题 / 列表 / 表格 / 图片 / 链接 / 行内强调)
|
||||
* - 属性:仅保留各标签的排版必需属性 + colspan/rowspan 等表格结构属性
|
||||
* - 协议:href/src 仅允许 http / https / mailto / 站内相对路径 / data:image
|
||||
* - style:保留(wangEditor 靠它输出对齐与颜色),但剔除 url()/expression() 等高危片段
|
||||
*
|
||||
* 未知标签走「解包」而非「整体删除」,避免把内容一起吃掉;危险标签整体丢弃并连同子树。
|
||||
*/
|
||||
|
||||
// 整体丢弃(连同子树)——脚本/样式/嵌入对象/表单交互类
|
||||
const DROP_TAGS = new Set([
|
||||
'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
|
||||
'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option',
|
||||
'svg', 'math', 'template', 'noscript', 'audio', 'video', 'source', 'track',
|
||||
'canvas', 'portal', 'dialog', 'marquee',
|
||||
])
|
||||
|
||||
// 允许保留的标签(其余走解包)
|
||||
const ALLOW_TAGS = new Set([
|
||||
'p', 'br', 'hr', 'div', 'span', 'section', 'article',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
||||
'blockquote', 'pre', 'code',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col', 'tr', 'th', 'td',
|
||||
'a', 'img', 'font',
|
||||
])
|
||||
|
||||
// 允许保留的属性(全局)
|
||||
const ALLOW_ATTRS_GLOBAL = new Set(['style', 'class', 'title', 'dir', 'lang'])
|
||||
|
||||
// 标签私有属性白名单
|
||||
const ALLOW_ATTRS_BY_TAG = {
|
||||
a: new Set(['href', 'target', 'rel']),
|
||||
img: new Set(['src', 'alt', 'width', 'height']),
|
||||
td: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height']),
|
||||
th: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'scope']),
|
||||
col: new Set(['span', 'width']),
|
||||
colgroup: new Set(['span', 'width']),
|
||||
table: new Set(['border', 'cellpadding', 'cellspacing', 'width', 'align']),
|
||||
ol: new Set(['start', 'type']),
|
||||
font: new Set(['color', 'size', 'face']),
|
||||
}
|
||||
|
||||
// style 属性内的危险片段(命中即整条 style 丢弃,宁可丢排版不丢安全)
|
||||
const DANGEROUS_STYLE = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|\/\*/i
|
||||
|
||||
// 危险协议(含被空白/控制字符混淆的变体,如 "java\tscript:")
|
||||
const DANGEROUS_PROTOCOL = /^(?:javascript|vbscript|file|blob)\s*:/i
|
||||
|
||||
/**
|
||||
* 归一化协议文本:去掉 ASCII 控制字符与零宽字符后判协议
|
||||
* (防止 "java	script:" 之类的混淆绕过)
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeUrl(raw) {
|
||||
return String(raw || '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u0020\u007f\u200b-\u200f\ufeff]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 URL 属性值是否安全
|
||||
* @param {string} raw - 原始属性值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(raw) {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return false
|
||||
const normalized = normalizeUrl(value)
|
||||
if (DANGEROUS_PROTOCOL.test(normalized)) return false
|
||||
if (normalized.startsWith('data:')) {
|
||||
// 仅允许内联图片,杜绝 data:text/html(可执行脚本)
|
||||
return /^data:image\/(png|jpe?g|gif|webp|bmp|svg\+xml);/i.test(normalized)
|
||||
}
|
||||
// 站内相对路径、锚点、以及 http/https/mailto/tel 放行
|
||||
return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i.test(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归消毒 DOM 子树
|
||||
* @param {Node} node
|
||||
*/
|
||||
function sanitizeNode(node) {
|
||||
// 从后往前遍历,删除时不影响未处理节点的索引
|
||||
const children = Array.from(node.childNodes)
|
||||
for (const child of children) {
|
||||
// 文本节点:保留
|
||||
if (child.nodeType === Node.TEXT_NODE) continue
|
||||
// 注释 / CDATA / 处理指令:整体丢弃
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
child.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
const tag = child.tagName.toLowerCase()
|
||||
|
||||
if (DROP_TAGS.has(tag)) {
|
||||
child.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!ALLOW_TAGS.has(tag)) {
|
||||
// 未知标签:解包(把子节点提到父级),保留其内容但去掉标签语义
|
||||
sanitizeNode(child)
|
||||
while (child.firstChild) {
|
||||
node.insertBefore(child.firstChild, child)
|
||||
}
|
||||
child.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
sanitizeElementAttrs(child, tag)
|
||||
sanitizeNode(child)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛单个元素的属性
|
||||
* @param {Element} el
|
||||
* @param {string} tag - 小写标签名
|
||||
*/
|
||||
function sanitizeElementAttrs(el, tag) {
|
||||
const tagAttrs = ALLOW_ATTRS_BY_TAG[tag] || new Set()
|
||||
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase()
|
||||
const value = attr.value
|
||||
|
||||
// 所有 on* 事件属性一律移除;data-* 也一律移除(无渲染需要)
|
||||
if (name.startsWith('on') || name.startsWith('data-')) {
|
||||
el.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!ALLOW_ATTRS_GLOBAL.has(name) && !tagAttrs.has(name)) {
|
||||
el.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'style') {
|
||||
if (DANGEROUS_STYLE.test(value)) {
|
||||
el.removeAttribute(attr.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'href' || name === 'src') {
|
||||
if (!isSafeUrl(value)) {
|
||||
el.removeAttribute(attr.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'target') {
|
||||
// 只允许新窗口,并强制补 rel 防 window.opener 劫持
|
||||
if (value !== '_blank') el.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'rel') {
|
||||
continue
|
||||
}
|
||||
|
||||
// 数值型结构属性:仅允许纯数字
|
||||
if (['colspan', 'rowspan', 'span', 'start', 'width', 'height', 'border',
|
||||
'cellpadding', 'cellspacing'].includes(name)) {
|
||||
if (!/^\d+$/.test(String(value).trim())) {
|
||||
el.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === 'a' && el.getAttribute('target') === '_blank') {
|
||||
el.setAttribute('rel', 'noopener noreferrer nofollow')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消毒富文本 HTML,返回可直接用于 v-html 的安全字符串
|
||||
*
|
||||
* @param {string} html - 原始富文本 HTML
|
||||
* @returns {string} 消毒后的 HTML(非法输入返回空串)
|
||||
*/
|
||||
export function sanitizeHtml(html) {
|
||||
const raw = String(html == null ? '' : html)
|
||||
if (!raw.trim()) return ''
|
||||
|
||||
// 无 DOM 环境(如 SSR/单测)时退化为「剥标签」而不是原样返回,避免降级后反而放开注入面
|
||||
if (typeof window === 'undefined' || typeof window.DOMParser === 'undefined') {
|
||||
return raw.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
try {
|
||||
// 用 <template> 承载:内容落在 inert 的 DocumentFragment 中,不会执行脚本,
|
||||
// 也不存在「被内容里的 </div> 提前闭合包装层导致越界/丢内容」的问题。
|
||||
const tpl = document.createElement('template')
|
||||
tpl.innerHTML = raw
|
||||
sanitizeNode(tpl.content)
|
||||
return tpl.innerHTML
|
||||
} catch (e) {
|
||||
// 解析失败一律降级为纯文本,绝不回退到原始 HTML
|
||||
return raw.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断富文本内容是否为空(含只有空白/空标签/ 的情况)
|
||||
* @param {string} html
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRichTextEmpty(html) {
|
||||
const text = String(html == null ? '' : html)
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /gi, '')
|
||||
.replace(/[\s\u3000]/g, '')
|
||||
return text.length === 0
|
||||
}
|
||||
2021
src/views/Admin.vue
2021
src/views/Admin.vue
File diff suppressed because it is too large
Load Diff
@@ -128,19 +128,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div class="nav-section" v-if="examStore.wordTemplate">
|
||||
<div class="nav-section-title">Word 答题</div>
|
||||
<!-- 综合大题(中职/高职,替代原 Word 上传题) -->
|
||||
<div class="nav-section" v-if="examStore.bigQuestionEnabled">
|
||||
<div class="nav-section-title">
|
||||
综合大题
|
||||
<span v-if="examStore.bigLoaded" class="nav-subcount">
|
||||
(已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问)
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-nav">
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'nav-answered': wordUploaded || uploadedWordFile,
|
||||
'nav-current': currentQuestionIndex === wordQuestionIndex,
|
||||
'nav-answered': examStore.bigAllAnswered,
|
||||
'nav-current': currentQuestionIndex === bigQuestionIndex,
|
||||
}"
|
||||
@click="currentQuestionIndex = wordQuestionIndex"
|
||||
@click="currentQuestionIndex = bigQuestionIndex"
|
||||
>
|
||||
{{ wordQuestionIndex + 1 }}
|
||||
{{ bigQuestionIndex + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +164,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧题目区域 -->
|
||||
<div class="exam-main" v-if="examStore.questions.length > 0">
|
||||
<div class="exam-main" v-if="hasExamContent">
|
||||
<!-- 选择题 -->
|
||||
<div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card">
|
||||
<div class="question-header">
|
||||
@@ -224,75 +229,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div v-if="currentQuestionIndex === wordQuestionIndex" class="question-card word-card">
|
||||
<!-- 综合大题(中职/高职):题干富文本 + 三小问作答 -->
|
||||
<div v-if="isCurrentBig" class="question-card big-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordQuestionIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合题</el-tag>
|
||||
<span class="question-number">第 {{ bigQuestionIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合大题</el-tag>
|
||||
<span class="question-score" v-if="examStore.bigLoaded">
|
||||
(每小题 {{ examStore.bigScorePerSub }} 分,共 {{ examStore.bigTotalScore }} 分)
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-body">
|
||||
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传。</p>
|
||||
|
||||
<div class="word-sections">
|
||||
<!-- 下载区域 -->
|
||||
<div class="word-section">
|
||||
<h4>第 1 步:下载模板</h4>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
v-guard="() => downloadTemplate()"
|
||||
:loading="wordDownloading"
|
||||
>
|
||||
下载 Word 答题模板
|
||||
</el-button>
|
||||
<!-- 加载中 -->
|
||||
<div class="question-body" v-if="examStore.bigLoading">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<div v-if="wordDownloading" class="word-progress">
|
||||
<el-progress :percentage="wordDownloadPercent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="word-section">
|
||||
<h4>第 2 步:上传答卷</h4>
|
||||
<el-upload
|
||||
class="word-upload"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="onWordFileChange"
|
||||
:file-list="wordFileList"
|
||||
accept=".doc,.docx"
|
||||
>
|
||||
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
拖拽或 <em>点击上传</em> Word 文件
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">仅支持 .doc / .docx 文件</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="wordUploading" class="word-progress">
|
||||
<el-progress :percentage="wordUploadPercent" />
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedWordFile" class="uploaded-info">
|
||||
<el-tag type="success" size="small">已上传</el-tag>
|
||||
<span class="uploaded-name">{{ uploadedWordFile.fileName }}</span>
|
||||
<span class="uploaded-size">{{ uploadedWordFile.fileSize }}</span>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="wordDeleting"
|
||||
v-guard="() => deleteWordFile()"
|
||||
>删除并重新上传</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 抽题失败:给出明确原因与重试入口(不静默留白) -->
|
||||
<div class="question-body" v-else-if="!examStore.bigLoaded">
|
||||
<el-alert
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="examStore.bigError || '综合大题加载失败'"
|
||||
description="若提示「该组别暂无大题」,请联系监考老师;若为网络问题,可点击下方按钮重试。"
|
||||
/>
|
||||
<div class="big-retry">
|
||||
<el-button type="primary" :loading="examStore.bigLoading" v-guard="() => retryBigQuestion()">
|
||||
重新加载题目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题干 + 三小问作答 -->
|
||||
<div class="question-body" v-else>
|
||||
<h3 class="big-title">{{ examStore.bigQuestion.title }}</h3>
|
||||
|
||||
<!-- 富文本题干(含表格):经白名单消毒后渲染 -->
|
||||
<div class="big-description" v-html="bigDescriptionHtml"></div>
|
||||
|
||||
<el-alert
|
||||
v-if="!examStore.bigAllAnswered"
|
||||
class="big-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="请在下方三个输入框中分别作答,每个输入框最多 2000 字,作答内容会实时自动保存。"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="i in examStore.bigSubCount"
|
||||
:key="i"
|
||||
class="big-sub-item"
|
||||
>
|
||||
<div class="big-sub-header">
|
||||
<span class="big-sub-no">第 {{ i }} 小问</span>
|
||||
<span class="big-sub-score">({{ examStore.bigScorePerSub }} 分)</span>
|
||||
</div>
|
||||
<div v-if="examStore.bigSubPrompts[i - 1]" class="big-sub-prompt">{{ examStore.bigSubPrompts[i - 1] }}</div>
|
||||
<el-input
|
||||
class="big-answer-input"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
resize="vertical"
|
||||
:maxlength="examStore.bigMaxChars"
|
||||
show-word-limit
|
||||
:placeholder="`请输入第 ${i} 小问的答案(最多 ${examStore.bigMaxChars} 字)`"
|
||||
:model-value="bigAnswerOf(i)"
|
||||
@update:model-value="val => onBigAnswerInput(i, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 保存状态:让考生对「是否已存上」有明确反馈 -->
|
||||
<div class="big-save-status">
|
||||
<el-tag v-if="examStore.bigSaving" size="small" type="warning" effect="plain">正在保存…</el-tag>
|
||||
<el-tag v-else-if="bigSaveErrorShown" size="small" type="danger" effect="plain">
|
||||
保存失败:{{ examStore.bigError }}
|
||||
</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">已自动保存</el-tag>
|
||||
<span class="big-save-note">已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-footer">
|
||||
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
|
||||
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
|
||||
@@ -312,7 +329,9 @@
|
||||
<div class="exam-bottom-bar" v-if="!isUploadMode">
|
||||
<div class="exam-bottom-inner">
|
||||
<span class="submit-hint">
|
||||
请确保已检查所有题目和 Word 文件后提交
|
||||
{{ examStore.bigQuestionEnabled
|
||||
? `请确保已检查所有题目和综合大题(已答 ${examStore.bigAnsweredCount}/${examStore.bigSubCount} 小问)后提交`
|
||||
: '请确保已检查所有题目后提交' }}
|
||||
</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -343,12 +362,13 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import { Timer, Download, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { Timer, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import * as examApi from '@/api/exam'
|
||||
import * as uploadApi from '@/api/upload'
|
||||
import { formatFileSize } from '@/utils/upload'
|
||||
import { sanitizeHtml } from '@/utils/richtext'
|
||||
import {
|
||||
fileExt,
|
||||
categoryOf,
|
||||
@@ -374,7 +394,6 @@ const uploadRef = ref(null)
|
||||
|
||||
const ZIP_MAX = 200 * 1024 * 1024
|
||||
const OTHER_MAX = 100 * 1024 * 1024
|
||||
const WORD_MAX = 50 * 1024 * 1024 // 与后端 uploadWord 服务端 50MB 上限一致
|
||||
|
||||
function fileTypeOf(name) {
|
||||
const e = '.' + fileExt(name)
|
||||
@@ -509,32 +528,45 @@ const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const currentQuestionIndex = ref(0)
|
||||
|
||||
// Word 上传相关
|
||||
const wordFileList = ref([])
|
||||
const wordUploading = ref(false)
|
||||
const wordUploadPercent = ref(0)
|
||||
const wordDeleting = ref(false)
|
||||
const uploadedWordFile = ref(null)
|
||||
const wordDownloading = ref(false)
|
||||
const wordDownloadPercent = ref(0)
|
||||
const wordUploaded = ref(false)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// 综合大题作为最后一道「题」参与导航与题量统计(中职/高职启用,本科/教师不启用)
|
||||
const totalCount = computed(() => {
|
||||
const base = examStore.questions.length
|
||||
return examStore.wordTemplate ? base + 1 : base
|
||||
return examStore.bigQuestionEnabled ? base + 1 : base
|
||||
})
|
||||
|
||||
// Word 题目的虚拟索引(最后一个)
|
||||
const wordQuestionIndex = computed(() => examStore.questions.length)
|
||||
// 综合大题的虚拟索引(最后一个)
|
||||
const bigQuestionIndex = computed(() => examStore.questions.length)
|
||||
|
||||
// 当前是否停在综合大题
|
||||
const isCurrentBig = computed(() => {
|
||||
return examStore.bigQuestionEnabled && currentQuestionIndex.value === bigQuestionIndex.value
|
||||
})
|
||||
|
||||
// 右侧题目区是否有内容可渲染(客观题与综合大题任一可用即渲染,避免只抽到大题时误显示空状态)
|
||||
const hasExamContent = computed(() => {
|
||||
return examStore.questions.length > 0 || (examStore.bigQuestionEnabled && examStore.bigLoaded)
|
||||
})
|
||||
|
||||
const answeredCount = computed(() => {
|
||||
let count = Object.keys(examStore.answers).length
|
||||
if (wordUploaded.value || uploadedWordFile.value) count++
|
||||
// 综合大题按「三小问全部作答」计为 1 题已答(部分作答可在侧栏看到 x/3 明细)
|
||||
if (examStore.bigQuestionEnabled && examStore.bigAllAnswered) count++
|
||||
return count
|
||||
})
|
||||
|
||||
/** 题干富文本:白名单消毒后再交给 v-html 渲染 */
|
||||
const bigDescriptionHtml = computed(() => {
|
||||
const q = examStore.bigQuestion
|
||||
return sanitizeHtml((q && q.description) || '')
|
||||
})
|
||||
|
||||
/** 是否处于「保存失败」状态(保存中优先展示加载态,失败时给出明确提示) */
|
||||
const bigSaveErrorShown = computed(() => {
|
||||
return !examStore.bigSaving && !!examStore.bigError && examStore.bigLoaded
|
||||
})
|
||||
|
||||
const remainingSeconds = computed(() => examStore.countdown)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
@@ -625,170 +657,56 @@ function nextQuestion() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Word 相关 ====================
|
||||
// ==================== 综合大题(中职/高职) ====================
|
||||
|
||||
/** 下载 Word 模板 */
|
||||
async function downloadTemplate() {
|
||||
if (!examStore.wordTemplate?.group) return
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/exam/${examStore.wordTemplate.group}/word-template?token=${encodeURIComponent(token || '')}`
|
||||
/** 取第 index 小问的当前答案(空值统一为 '',避免 el-input 出现 undefined 告警) */
|
||||
function bigAnswerOf(index) {
|
||||
const v = examStore.bigAnswers[`answer_${index}`]
|
||||
return v == null ? '' : v
|
||||
}
|
||||
|
||||
wordDownloading.value = true
|
||||
wordDownloadPercent.value = 0
|
||||
try {
|
||||
// 同时带上 Authorization 请求头(与上传一致),确保 auth 校验通过
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : undefined
|
||||
const response = await fetch(url, headers ? { headers } : undefined)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
/** 输入即写本地 + 防抖自动保存(落库由 store 统一处理) */
|
||||
function onBigAnswerInput(index, value) {
|
||||
examStore.setBigAnswer(index, value)
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
if (data.code !== 0) {
|
||||
ElMessage.warning(data.message || '模板下载失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
ElMessage.error('模板下载失败')
|
||||
return
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('content-disposition')
|
||||
let filename = 'template.docx'
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i)
|
||||
if (match) {
|
||||
filename = decodeURIComponent(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 流式读取,实时更新下载进度(1M 带宽下大文件较慢,必须有反馈,
|
||||
// 消除「点击后无反应」的错觉,与上传进度体验一致)。
|
||||
const reader = response.body.getReader()
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0', 10)
|
||||
let received = 0
|
||||
const chunks = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
if (contentLength > 0) {
|
||||
wordDownloadPercent.value = Math.min(99, Math.floor((received / contentLength) * 100))
|
||||
} else {
|
||||
// 无 content-length(被分块/压缩)时给一个随已下载量缓增的估算进度,封顶 95
|
||||
wordDownloadPercent.value = Math.min(95, Math.floor((received / (5 * 1024 * 1024)) * 100))
|
||||
}
|
||||
}
|
||||
const blob = new Blob(chunks)
|
||||
|
||||
// 🔴 同源守卫(2026-08-13 推进 #12 / 同源扩大化测试):本函数走裸 fetch,
|
||||
// 不经过 safeDownload/fetchBlobGuarded 的 assertRealFile。若 nginx /internal-downloads/
|
||||
// 内部 location 未生效而回退吐 index.html(text/html),原逻辑会把 HTML 当 .docx/.pptx
|
||||
// 存盘 → 打开「已损坏」。这里补同样的 HTML 嗅探 + X-File-Size 大小比对,命中即拒存盘。
|
||||
const head = await blob.slice(0, 256).text().catch(() => '');
|
||||
if (/^\s*(<!doctype\s+html|<html[\s>])/i.test(head)) {
|
||||
ElMessage.error('服务器返回的是网页而非模板文件,下载通道配置异常(请联系管理员检查 nginx 内部下载 location)');
|
||||
return;
|
||||
}
|
||||
const declared = parseInt(response.headers.get('x-file-size') || response.headers.get('content-length') || '0', 10);
|
||||
if (Number.isFinite(declared) && declared > 0 && received !== declared) {
|
||||
ElMessage.error(`模板传输不完整:应为 ${declared} 字节,实际 ${received} 字节,请重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
wordDownloadPercent.value = 100
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
} catch (err) {
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
wordDownloading.value = false
|
||||
/** 抽题/加载失败后的手动重试 */
|
||||
async function retryBigQuestion() {
|
||||
const ok = await examStore.loadBigQuestion(examStore.currentGroup)
|
||||
if (ok) {
|
||||
ElMessage.success('综合大题已加载')
|
||||
} else {
|
||||
ElMessage.error(examStore.bigError || '综合大题加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** Word 文件选择变化 */
|
||||
function onWordFileChange(file) {
|
||||
const raw = file.raw || file
|
||||
// 客户端先行校验大小(与后端 uploadWord 50MB 上限一致),超限直接拦截并给出带文件大小的明确提示
|
||||
if (raw.size > WORD_MAX) {
|
||||
ElMessage.error(`Word 文件「${raw.name}」大小为 ${formatFileSize(raw.size)},超过 50MB 限制,无法上传`)
|
||||
wordFileList.value = []
|
||||
return
|
||||
}
|
||||
wordFileList.value = [file]
|
||||
uploadWordFile()
|
||||
}
|
||||
|
||||
/** 上传 Word 文件 */
|
||||
async function uploadWordFile() {
|
||||
if (wordFileList.value.length === 0) return null
|
||||
const rawFile = wordFileList.value[0]?.raw || wordFileList.value[0]
|
||||
if (!rawFile) return null
|
||||
|
||||
wordUploading.value = true
|
||||
wordUploadPercent.value = 0
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', rawFile)
|
||||
|
||||
const result = await examApi.uploadWord(examStore.currentGroup, formData)
|
||||
wordUploadPercent.value = 100
|
||||
wordUploaded.value = true
|
||||
|
||||
uploadedWordFile.value = {
|
||||
fileName: rawFile.name,
|
||||
fileSize: formatFileSize(rawFile.size),
|
||||
md5: result?.md5 || '',
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
ElMessage.error('Word 文件上传失败')
|
||||
return null
|
||||
} finally {
|
||||
wordUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除已上传的 Word 文件(支持重新上传替换) */
|
||||
async function deleteWordFile() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除已上传的 Word 文件吗?删除后可重新上传进行替换。',
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
wordDeleting.value = true
|
||||
try {
|
||||
await examApi.deleteWord(examStore.currentGroup)
|
||||
uploadedWordFile.value = null
|
||||
wordFileList.value = []
|
||||
wordUploaded.value = false
|
||||
ElMessage.success('Word 文件已删除,可重新上传')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.response?.data?.message || '删除失败,请重试')
|
||||
} finally {
|
||||
wordDeleting.value = false
|
||||
/**
|
||||
* 交卷前确保综合大题答案已落库
|
||||
* @param {{force?: boolean}} [opts]
|
||||
* @returns {Promise<boolean>} 是否落库成功
|
||||
*/
|
||||
async function ensureBigAnswersSaved(opts = {}) {
|
||||
if (!examStore.bigQuestionEnabled || !examStore.bigLoaded) return true
|
||||
const r = await examStore.flushBigAnswers(opts)
|
||||
if (!r.ok) {
|
||||
ElMessage.error(`综合大题答案保存失败:${r.message || '请检查网络后重试'}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 提交全部 ====================
|
||||
|
||||
async function handleSubmitAll() {
|
||||
// 未答满时给出明确的「还差什么」,避免考生误交卷
|
||||
let confirmText = '确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。'
|
||||
if (examStore.bigQuestionEnabled && examStore.bigLoaded && !examStore.bigAllAnswered) {
|
||||
const lack = examStore.bigSubCount - examStore.bigAnsweredCount
|
||||
confirmText = `综合大题还有 ${lack} 个小问未作答,提交后将无法修改。确定要提交全部答案吗?`
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。',
|
||||
confirmText,
|
||||
'确认提交',
|
||||
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
|
||||
)
|
||||
@@ -798,19 +716,21 @@ async function handleSubmitAll() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
|
||||
await uploadWordFile()
|
||||
}
|
||||
// 先把综合大题答案落库(submit-all 只处理客观题与提交时间),失败即中止交卷
|
||||
const saved = await ensureBigAnswersSaved()
|
||||
if (!saved) return
|
||||
|
||||
const additionalData = {}
|
||||
if (uploadedWordFile.value) {
|
||||
additionalData.wordFile = uploadedWordFile.value
|
||||
}
|
||||
|
||||
await examStore.submitAll(additionalData)
|
||||
await examStore.submitAll({})
|
||||
ElMessage.success('提交成功!')
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
// 交卷失败:答案已保留在本地草稿中,须明确告知可重试,
|
||||
// 避免考生误以为「已经交上了」而离开页面。
|
||||
const msg = (err && err.message) || ''
|
||||
if (msg) {
|
||||
ElMessage.error(msg)
|
||||
} else {
|
||||
ElMessage.error('提交未完成,请检查网络后重试(您的答案已在本机保留)')
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -839,27 +759,35 @@ async function autoSubmit() {
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
|
||||
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案,请勿关闭页面')
|
||||
try {
|
||||
// 若尚有未上传的 Word 答题文件,先上传,避免作品被遗漏
|
||||
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
|
||||
await uploadWordFile()
|
||||
// forceSubmit:倒计时归零即合法交卷,豁免「未答满全部题目」闸门,
|
||||
// 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
|
||||
// ⚠️ 注意 forceSubmit **不再**能够绕过「考试窗口已关闭」——窗口关闭后由服务端
|
||||
// 走结算通道(以库内已落库作答为准补写 submit_time),这正是本流程的兜底。
|
||||
const saved = await ensureBigAnswersSaved({ force: true })
|
||||
if (!saved) {
|
||||
// 大题落库失败不退化为静默丢答案:提示考生手动重试
|
||||
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
|
||||
autoSubmitted.value = false
|
||||
return
|
||||
}
|
||||
const additionalData = {}
|
||||
if (uploadedWordFile.value) {
|
||||
additionalData.wordFile = uploadedWordFile.value
|
||||
}
|
||||
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
|
||||
// 确保 submit_time 落库(管理后台提交时间显示为真实交卷时间,而非进入考试时间)。
|
||||
additionalData.forceSubmit = true
|
||||
// submitAll 内部会逐题保存答案并写入提交时间,完成「自动保存 + 自动提交」
|
||||
await examStore.submitAll(additionalData)
|
||||
// submitAll 内含自动重试(网络层失败 / 429 / 5xx 会指数退避重试 6 次),
|
||||
// 每次重试都带上内存中的全部客观题答案做一次全量落库。
|
||||
await examStore.submitAll({ forceSubmit: true })
|
||||
ElMessage.success('已自动保存并提交')
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
autoSubmitted.value = false
|
||||
console.error('自动提交失败', err)
|
||||
ElMessage.error('自动提交失败,请手动点击「提交全部」')
|
||||
// 🔴 不再只提示「请手动点击提交全部」:重试若干次仍失败说明网络或服务端持续不可用,
|
||||
// 此时答案已保留在本地草稿中(见 stores/exam.js),须明确告知考生可重试,
|
||||
// 否则考生会误以为已经交上卷。窗口关闭后服务端仍接受结算补交,故重试始终有效。
|
||||
ElMessageBox.alert(
|
||||
'自动提交未能完成(网络异常或服务器繁忙)。您的答案已在本机保留,请点击「提交全部」重试;若仍失败,请立即联系监考老师。',
|
||||
'提交未完成',
|
||||
{ confirmButtonText: '我知道了', type: 'error' }
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,27 +855,44 @@ function handleBeforeUnload(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止复制
|
||||
/**
|
||||
* 事件目标是否为「可编辑元素」(综合大题的作答输入框)
|
||||
*
|
||||
* 考试页为保证题目不被外传,全局禁用了选中/复制/右键。但综合大题要求考生
|
||||
* 在输入框内自行作答,若沿用同一套拦截会导致无法选中、修改、粘贴自己写的内容。
|
||||
* 因此对可编辑元素做最小豁免:只放行「编辑自身答案」所需的操作,
|
||||
* 题目区域(非可编辑)的防护行为完全不变。
|
||||
*/
|
||||
function isEditableTarget(e) {
|
||||
const el = e && e.target
|
||||
if (!el) return false
|
||||
const tag = (el.tagName || '').toLowerCase()
|
||||
return tag === 'textarea' || tag === 'input' || el.isContentEditable === true
|
||||
}
|
||||
|
||||
// 禁止复制(可编辑元素内放行,否则考生无法复制/粘贴自己的作答)
|
||||
function preventCopy(e) {
|
||||
if (isEditableTarget(e)) return
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止鼠标选中文字
|
||||
// 禁止鼠标选中文字(可编辑元素内放行,否则输入框内无法选中文字)
|
||||
function preventSelect(e) {
|
||||
if (isEditableTarget(e)) return
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止键盘快捷键:Ctrl+C、Ctrl+U、F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J
|
||||
function preventKeyboard(e) {
|
||||
// F12(开发者工具)
|
||||
// F12(开发者工具)—— 任何位置均拦截
|
||||
if (e.key === 'F12' || e.keyCode === 123) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)
|
||||
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)—— 任何位置均拦截
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
if (e.key === 'I' || e.key === 'i' ||
|
||||
e.key === 'C' || e.key === 'c' ||
|
||||
@@ -957,6 +902,9 @@ function preventKeyboard(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 以下剪贴/保存/打印类快捷键:仅在可编辑元素内放行(考生编辑自身答案所需)
|
||||
if (isEditableTarget(e)) return
|
||||
|
||||
// Ctrl+C(复制)
|
||||
if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) {
|
||||
e.preventDefault()
|
||||
@@ -1072,29 +1020,45 @@ onMounted(async () => {
|
||||
|
||||
await examStore.startExam(group)
|
||||
|
||||
if (examStore.questions.length === 0) {
|
||||
ElMessage.error('题目加载失败,请刷新页面重试')
|
||||
// 客观题与综合大题都为空才算加载失败;只抽到大题(客观题库为空)时仍可正常作答
|
||||
if (examStore.questions.length === 0 && !examStore.bigLoaded) {
|
||||
ElMessage.error(examStore.bigError || '题目加载失败,请刷新页面重试')
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复,
|
||||
// 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。
|
||||
// 🔴 2026-09-21 加固:服务端答案恢复后**必须**再用本地草稿补齐 ——
|
||||
// 逐题保存失败的作答只存在于本机 localStorage,若不合并草稿,
|
||||
// 「断网期间作答后刷新」会静默丢失这批内容。两条路径都要尝试草稿恢复,
|
||||
// 且草稿恢复失败绝不能中断进入考试。
|
||||
try {
|
||||
const answersData = await examApi.getAnswers(group)
|
||||
const restored = {}
|
||||
if (answersData && answersData.answers && answersData.answers.length > 0) {
|
||||
const restored = {}
|
||||
answersData.answers.forEach(a => {
|
||||
if (a.answer_data) {
|
||||
restored[a.question_uid] = a.answer_data
|
||||
}
|
||||
})
|
||||
if (Object.keys(restored).length > 0) {
|
||||
examStore.restoreAnswers(restored)
|
||||
}
|
||||
}
|
||||
examStore.restoreAnswers(restored)
|
||||
// 草稿只补「服务端没有」的题,绝不覆盖服务端已有作答
|
||||
const merged = examStore.restoreDraft()
|
||||
if (merged && merged.merged > 0) {
|
||||
ElMessage.warning(`已从本机恢复 ${merged.merged} 道未同步到服务器的作答,请核对后再交卷`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('恢复已有答案失败', e)
|
||||
console.warn('恢复已有答案失败,尝试仅用本地草稿恢复', e)
|
||||
try {
|
||||
const merged = examStore.restoreDraft()
|
||||
if (merged && merged.merged > 0) {
|
||||
ElMessage.warning(`服务器暂时不可用,已从本机恢复 ${merged.merged} 道作答`)
|
||||
}
|
||||
} catch (e2) {
|
||||
console.warn('本地草稿恢复失败', e2)
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
@@ -1350,26 +1314,116 @@ onUnmounted(() => {
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* ==================== Word 答题卡片 ==================== */
|
||||
.word-card .word-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.word-section {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.word-section h4 {
|
||||
font-size: 0.95rem;
|
||||
/* ==================== 综合大题卡片 ==================== */
|
||||
.big-card .big-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 富文本题干:scoped CSS 不作用于 v-html 子节点,须用 :deep() 兜底可读性 */
|
||||
.big-description {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
margin-bottom: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.big-description :deep(p) {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.big-description :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.big-description :deep(th),
|
||||
.big-description :deep(td) {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 0.5rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.big-description :deep(th) {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.big-description :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.big-hint {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.big-sub-item {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
}
|
||||
.big-sub-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.big-sub-no {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.word-upload {
|
||||
width: 100%;
|
||||
.big-sub-score {
|
||||
font-size: 0.85rem;
|
||||
color: #909399;
|
||||
}
|
||||
.word-progress {
|
||||
margin-top: 0.75rem;
|
||||
.big-sub-prompt {
|
||||
font-size: 0.9rem;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 0.5rem;
|
||||
/* 小题提示为纯文本,保留换行与缩进 */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 作答输入框:考试页全局禁用选中,这里必须恢复,否则考生无法编辑自己的答案 */
|
||||
.big-answer-input :deep(.el-textarea__inner) {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
/* 字数统计(el-input show-word-limit 默认渲染在右下角) */
|
||||
.big-answer-input :deep(.el-input__count) {
|
||||
background: transparent;
|
||||
color: #909399;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.big-save-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.big-save-note {
|
||||
font-size: 0.85rem;
|
||||
color: #909399;
|
||||
}
|
||||
.big-retry {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* 侧栏综合大题小问计数 */
|
||||
.nav-subcount {
|
||||
font-size: 0.8rem;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
.uploaded-info {
|
||||
display: flex;
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<!-- 最后一题:中职/高职 = 综合大题;本科/教师 = Word 答题(与正式考试一致) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Word 答题</div>
|
||||
<div class="nav-section-title">{{ isBigQuestionGroup ? '综合大题' : 'Word 答题' }}</div>
|
||||
<div class="question-nav">
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'nav-answered': wordCompleted,
|
||||
'nav-answered': lastSectionCompleted,
|
||||
'nav-current': currentIndex === wordIndex,
|
||||
}"
|
||||
@click="currentIndex = wordIndex"
|
||||
@@ -166,8 +166,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div v-if="currentIndex === wordIndex" class="question-card word-card">
|
||||
<!-- 综合大题(中职/高职组)—— 正式考试已取消 Word 上传题,改为在线作答 -->
|
||||
<div v-if="currentIndex === wordIndex && isBigQuestionGroup" class="question-card word-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
|
||||
<el-tag type="primary" size="small">综合大题</el-tag>
|
||||
<span class="question-score">(30 分)</span>
|
||||
</div>
|
||||
<div class="question-body">
|
||||
<p class="question-text">
|
||||
模拟体验:正式考试中本题由系统从题库随机抽取(题干含文字描述与表格),
|
||||
共 {{ mockBigSubPrompts.length }} 小问,每小问 10 分、作答上限 {{ MOCK_BIG_MAX_CHARS }} 字,
|
||||
请直接在本页输入框中作答(模拟考试不会真实提交)。
|
||||
</p>
|
||||
|
||||
<div class="big-sections">
|
||||
<div v-for="(prompt, si) in mockBigSubPrompts" :key="'big-' + si" class="big-section">
|
||||
<h4>第 {{ si + 1 }} 小问(10 分)</h4>
|
||||
<p class="big-prompt">{{ prompt }}</p>
|
||||
<el-input
|
||||
v-model="bigAnswers[si]"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:maxlength="MOCK_BIG_MAX_CHARS"
|
||||
show-word-limit
|
||||
placeholder="请输入作答内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="question-footer">
|
||||
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
|
||||
<el-button type="primary" @click="nextQuestion">
|
||||
下一题
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题(本科/教师组;模拟考试,文件不会真实上传) -->
|
||||
<div v-if="currentIndex === wordIndex && !isBigQuestionGroup" class="question-card word-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合题</el-tag>
|
||||
@@ -226,7 +263,7 @@
|
||||
<div class="exam-bottom-bar">
|
||||
<div class="exam-bottom-inner">
|
||||
<span class="submit-hint">
|
||||
请确保已检查所有题目和 Word 文件后提交
|
||||
{{ isBigQuestionGroup ? '请确保已检查所有题目和综合大题后提交' : '请确保已检查所有题目和 Word 文件后提交' }}
|
||||
</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -267,7 +304,8 @@ const optionLabels = ['A', 'B', 'C', 'D']
|
||||
|
||||
// ==================== 模拟题目数据 ====================
|
||||
// 设计意图(与用户明确要求一致):
|
||||
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。
|
||||
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道
|
||||
// (+ 1 道最后一题:中职/高职为「综合大题」在线作答,本科/教师为 Word 上传)。
|
||||
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
|
||||
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
|
||||
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
|
||||
@@ -316,6 +354,24 @@ const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
|
||||
// 合并所有题目(选择题在前、判断题在后,与在线考试一致)
|
||||
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 最后一题的形态随组别变化(与正式考试保持一致,2026-09-21 改造):
|
||||
// · 中职组 / 高职组:正式考试已取消 Word 文件上传题,改为「综合大题」在线作答
|
||||
// (题干含文字描述与表格,3 小问、每小问 10 分、作答上限 2000 字)。
|
||||
// 模拟页必须同步呈现该形态,否则考生会在模拟里看到一道正式考试并不存在的题。
|
||||
// · 本科组 / 教师组:正式考试仍为 Word 文档上传,模拟页保持原样。
|
||||
// 注意:模拟页始终为**纯前端**演示,不对正式考试的题库/上传通道发起任何请求。
|
||||
// ---------------------------------------------------------------------------
|
||||
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
|
||||
const MOCK_BIG_MAX_CHARS = 2000
|
||||
|
||||
// 综合大题样题(3 小问,仅用于模拟演示,与正式题库无关)
|
||||
const mockBigSubPrompts = [
|
||||
'请简述你对题干所给方案的总体思路。',
|
||||
'请说明方案实施的关键步骤与所需条件。',
|
||||
'请分析该方案可能存在的问题并给出改进建议。',
|
||||
]
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const currentIndex = ref(0)
|
||||
const answers = ref({})
|
||||
@@ -324,13 +380,29 @@ const remainingSeconds = ref(3600) // 60分钟模拟
|
||||
const wordCompleted = ref(false)
|
||||
const wordFileName = ref('')
|
||||
const wordFileList = ref([])
|
||||
// 综合大题(中职/高职)模拟作答:与正式考试的 3 个输入框同口径
|
||||
const bigAnswers = ref(mockBigSubPrompts.map(() => ''))
|
||||
let timerInterval = null
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word
|
||||
// 当前用户组是否走「综合大题」形态
|
||||
const isBigQuestionGroup = computed(() => BIG_QUESTION_GROUPS.includes(authStore.userGroup))
|
||||
|
||||
const totalCount = computed(() => allQuestions.length + 1) // +1 为最后一题
|
||||
|
||||
const wordIndex = computed(() => allQuestions.length)
|
||||
|
||||
// 综合大题是否答满(3 小问均有非空内容,与后端 isCompleted 同口径)
|
||||
const bigCompleted = computed(() =>
|
||||
mockBigSubPrompts.length > 0 &&
|
||||
bigAnswers.value.slice(0, mockBigSubPrompts.length).every(v => String(v || '').trim().length > 0)
|
||||
)
|
||||
|
||||
// 最后一题是否已完成(按组别取对应口径,供导航着色与进度统计统一使用)
|
||||
const lastSectionCompleted = computed(() =>
|
||||
isBigQuestionGroup.value ? bigCompleted.value : wordCompleted.value
|
||||
)
|
||||
|
||||
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
|
||||
|
||||
const currentQuestion = computed(() => {
|
||||
@@ -342,7 +414,7 @@ const currentQuestion = computed(() => {
|
||||
|
||||
const answeredCount = computed(() => {
|
||||
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
|
||||
if (wordCompleted.value) count++
|
||||
if (lastSectionCompleted.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -789,6 +861,24 @@ onUnmounted(() => {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 综合大题(中职/高职组) */
|
||||
.big-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.big-section h4 {
|
||||
font-size: 15px;
|
||||
color: #1a3a5c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.big-prompt {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ==================== 底部提交栏 ==================== */
|
||||
.exam-bottom-bar {
|
||||
background: #fff;
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
<template>
|
||||
<div class="upload-page">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="upload-header">
|
||||
<div class="upload-header-left">
|
||||
<el-button text @click="$router.push('/')" class="back-btn">
|
||||
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
|
||||
</el-button>
|
||||
<el-button text @click="$router.push('/dashboard')" class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<h2 class="upload-title">作品提交</h2>
|
||||
<span class="group-tag">{{ groupLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="upload-content">
|
||||
<!-- 截止时间 -->
|
||||
<div class="deadline-card" v-if="deadline">
|
||||
<div class="deadline-info">
|
||||
<el-icon :size="20"><Clock /></el-icon>
|
||||
<span>提交截止时间:</span>
|
||||
<strong :class="{ 'deadline-near': isDeadlineNear }">{{ formatDeadline(deadline) }}</strong>
|
||||
</div>
|
||||
<div class="deadline-countdown" v-if="deadlineCountdown > 0">
|
||||
<span>剩余时间:</span>
|
||||
<strong>{{ formatCountdown(deadlineCountdown) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 教师组双上传区 -->
|
||||
<template v-if="isTeacher">
|
||||
<el-row :gutter="24">
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="upload-card">
|
||||
<h3 class="upload-card-title">教学方案</h3>
|
||||
<p class="upload-card-desc">请上传教学方案文件(PDF、Word 格式)</p>
|
||||
<file-upload-area
|
||||
group="jiaoshi"
|
||||
file-type="word"
|
||||
:max-size="100 * 1024 * 1024"
|
||||
accept=".pdf,.doc,.docx"
|
||||
@upload-success="onUploadSuccess('word', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="upload-card">
|
||||
<h3 class="upload-card-title">说课 PPT</h3>
|
||||
<p class="upload-card-desc">请上传说课 PPT 文件</p>
|
||||
<file-upload-area
|
||||
group="jiaoshi"
|
||||
file-type="ppt"
|
||||
:max-size="100 * 1024 * 1024"
|
||||
accept=".ppt,.pptx"
|
||||
@upload-success="onUploadSuccess('ppt', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<!-- 本科组双上传区(Word + PPT) -->
|
||||
<template v-else>
|
||||
<el-row :gutter="24">
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="upload-card">
|
||||
<h3 class="upload-card-title">参赛作品(Word)</h3>
|
||||
<p class="upload-card-desc">请上传参赛作品文档(PDF、Word 格式)</p>
|
||||
<file-upload-area
|
||||
:group="userGroup"
|
||||
file-type="word"
|
||||
:max-size="50 * 1024 * 1024"
|
||||
accept=".pdf,.doc,.docx"
|
||||
@upload-success="onUploadSuccess('word', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="upload-card">
|
||||
<h3 class="upload-card-title">参赛作品(PPT)</h3>
|
||||
<p class="upload-card-desc">请上传参赛作品演示文稿(PPT 格式)</p>
|
||||
<file-upload-area
|
||||
:group="userGroup"
|
||||
file-type="ppt"
|
||||
:max-size="50 * 1024 * 1024"
|
||||
accept=".ppt,.pptx"
|
||||
@upload-success="onUploadSuccess('ppt', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<!-- 上传历史 -->
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">提交记录</h3>
|
||||
<div v-loading="historyLoading">
|
||||
<el-table :data="submissionHistory" stripe empty-text="暂无提交记录">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="fileSize" label="大小" width="100">
|
||||
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uploadTime" label="上传时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.uploadTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="deletingId === row.id"
|
||||
v-guard="() => deleteSubmission(row)"
|
||||
>删除</el-button>
|
||||
<el-button text type="primary" size="small" @click="reupload">重新上传</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Upload.vue - 作品上传页面(需要登录,benke/jiaoshi 组)
|
||||
*
|
||||
* 功能:
|
||||
* 1. 截止时间倒计时
|
||||
* 2. 拖拽上传区域
|
||||
* 3. 文件类型/大小校验
|
||||
* 4. 分片上传 + 进度条
|
||||
* 5. MD5 哈希显示
|
||||
* 6. 上传历史列表
|
||||
* 7. 教师组双上传区(教学方案 + 说课PPT)
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted, h, resolveComponent } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
|
||||
import { ArrowLeft, HomeFilled, Clock, UploadFilled, Delete } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as uploadApi from '@/api/upload'
|
||||
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
const userGroup = computed(() => authStore.userGroup)
|
||||
|
||||
const groupLabel = computed(() => {
|
||||
const map = { benke: '本科组', jiaoshi: '教师组' }
|
||||
return map[userGroup.value] || userGroup.value
|
||||
})
|
||||
|
||||
const isTeacher = computed(() => userGroup.value === 'jiaoshi')
|
||||
|
||||
const isDeadlineNear = computed(() => {
|
||||
if (!deadline.value) return false
|
||||
const diff = new Date(deadline.value) - new Date()
|
||||
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
const deadline = ref('')
|
||||
const deadlineCountdown = ref(0)
|
||||
const submissionHistory = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const deletingId = ref(null)
|
||||
let deadlineTimer = null
|
||||
|
||||
// ==================== 文件上传子组件(内联实现) ====================
|
||||
|
||||
// 使用简单的内联上传逻辑替代子组件
|
||||
const uploadStates = reactive({})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDeadline(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
return formatFileSize(bytes || 0)
|
||||
}
|
||||
|
||||
function formatCountdown(seconds) {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (d > 0) return `${d}天 ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 启动截止倒计时 */
|
||||
function startDeadlineCountdown() {
|
||||
clearInterval(deadlineTimer)
|
||||
if (!deadline.value) return
|
||||
deadlineTimer = setInterval(() => {
|
||||
const diff = Math.floor((new Date(deadline.value) - new Date()) / 1000)
|
||||
deadlineCountdown.value = Math.max(0, diff)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/** 获取提交历史 */
|
||||
async function fetchHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const data = await uploadApi.getSubmissions(userGroup.value)
|
||||
if (Array.isArray(data)) {
|
||||
submissionHistory.value = data
|
||||
} else if (data?.list) {
|
||||
submissionHistory.value = data.list
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取提交历史失败', err)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取上传状态 */
|
||||
async function fetchUploadStatus() {
|
||||
try {
|
||||
const data = await uploadApi.getUploadStatus(userGroup.value)
|
||||
if (data?.deadline) {
|
||||
deadline.value = data.deadline
|
||||
startDeadlineCountdown()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取上传状态失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadSuccess(type, result) {
|
||||
ElMessage.success('文件上传成功')
|
||||
fetchHistory()
|
||||
}
|
||||
|
||||
/** 删除已上传的作品文件(本科/教师组) */
|
||||
async function deleteSubmission(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除「${row.fileName}」吗?删除后可重新上传进行替换。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = row.id
|
||||
try {
|
||||
await uploadApi.deleteWork(userGroup.value, row.id)
|
||||
ElMessage.success('文件已删除')
|
||||
fetchHistory()
|
||||
} catch (err) {
|
||||
ElMessage.error('删除失败,请重试')
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动到上传区以便重新上传 */
|
||||
function reupload() {
|
||||
const el = document.querySelector('.upload-card')
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 组别检查
|
||||
if (!['benke', 'jiaoshi'].includes(authStore.userGroup)) {
|
||||
ElMessage.warning('您所在的组别不需要提交作品')
|
||||
// 不强制跳转,避免闪烁
|
||||
}
|
||||
|
||||
fetchUploadStatus()
|
||||
fetchHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (deadlineTimer) clearInterval(deadlineTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ==================== 文件上传子区域组件模板 ==================== -->
|
||||
|
||||
<script>
|
||||
/**
|
||||
* file-upload-area 是一个内联组件
|
||||
* 为了保持 Vue SFC 的简洁性,我们用 defineComponent
|
||||
*/
|
||||
import { defineComponent, ref, reactive, h, resolveComponent, computed } from 'vue'
|
||||
import { ElMessage, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
|
||||
import { UploadFilled, Delete } from '@element-plus/icons-vue'
|
||||
import * as uploadApi from '@/api/upload'
|
||||
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
|
||||
|
||||
const FileUploadArea = defineComponent({
|
||||
name: 'FileUploadArea',
|
||||
props: {
|
||||
group: { type: String, required: true },
|
||||
fileType: { type: String, default: 'work' },
|
||||
maxSize: { type: Number, default: 50 * 1024 * 1024 },
|
||||
accept: { type: String, default: '.pdf,.doc,.docx,.ppt,.pptx,.zip' },
|
||||
},
|
||||
emits: ['upload-success'],
|
||||
setup(props, { emit }) {
|
||||
const fileList = ref([])
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const uploadedInfo = ref(null)
|
||||
|
||||
function validateFile(file) {
|
||||
const raw = file.raw || file
|
||||
if (raw.size > props.maxSize) {
|
||||
const maxMB = props.maxSize / (1024 * 1024)
|
||||
ElMessage.error(`文件大小不能超过 ${maxMB}MB`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleFileChange(file) {
|
||||
if (!validateFile(file)) {
|
||||
fileList.value = []
|
||||
return
|
||||
}
|
||||
fileList.value = [file]
|
||||
uploadedInfo.value = null
|
||||
// 选择文件后自动开始上传,提供即时反馈
|
||||
startUpload()
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
if (uploading.value) {
|
||||
ElMessage.warning('文件正在上传中,请等待完成后再操作')
|
||||
return
|
||||
}
|
||||
fileList.value = []
|
||||
uploadedInfo.value = null
|
||||
uploadPercent.value = 0
|
||||
}
|
||||
|
||||
async function startUpload() {
|
||||
// 防重入:选择文件会自动触发一次上传,用户若再点「开始上传」会造成同一文件
|
||||
// 双份分片流并发,既浪费连接池也可能让后端合并出错。
|
||||
if (uploading.value) return
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
const rawFile = fileList.value[0]?.raw || fileList.value[0]
|
||||
if (!rawFile) return
|
||||
|
||||
uploading.value = true
|
||||
uploadPercent.value = 0
|
||||
|
||||
try {
|
||||
// 计算 MD5 作为 fileId
|
||||
let fileId = ''
|
||||
try {
|
||||
fileId = await calculateMd5(rawFile)
|
||||
} catch (e) {
|
||||
console.warn('MD5 计算失败', e)
|
||||
fileId = rawFile.name + '_' + Date.now()
|
||||
}
|
||||
|
||||
// 分片上传
|
||||
const chunks = chunkFile(rawFile)
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const formData = new FormData()
|
||||
// ⚠️ Egg stream 模式下 stream.fields 仅解析「出现在文件字段之前」的普通字段。
|
||||
// 必须把文本字段放在 chunk(文件)之前,否则后端收不到 chunkIndex/fileId/fileName -> 400 缺参。
|
||||
formData.append('chunkIndex', chunks[i].index)
|
||||
formData.append('totalChunks', chunks[i].total)
|
||||
formData.append('fileId', fileId)
|
||||
formData.append('fileName', rawFile.name)
|
||||
formData.append('chunk', chunks[i].chunk, rawFile.name)
|
||||
|
||||
await uploadWithRetry(
|
||||
() => uploadApi.uploadChunk(props.group, formData),
|
||||
3, 1000
|
||||
)
|
||||
|
||||
uploadPercent.value = Math.round(((i + 1) / chunks.length) * 100)
|
||||
}
|
||||
|
||||
// 所有分片上传完成,调用合并接口(透传 file_type:word/ppt)
|
||||
await uploadApi.mergeChunks(props.group, {
|
||||
fileId,
|
||||
fileName: rawFile.name,
|
||||
totalChunks: chunks.length,
|
||||
work_name: rawFile.name,
|
||||
file_type: props.fileType,
|
||||
})
|
||||
|
||||
uploadedInfo.value = {
|
||||
fileName: rawFile.name,
|
||||
fileSize: rawFile.size,
|
||||
fileId,
|
||||
}
|
||||
|
||||
ElMessage.success('上传成功')
|
||||
emit('upload-success', { type: props.fileType, info: uploadedInfo.value })
|
||||
} catch (err) {
|
||||
ElMessage.error('上传失败,请重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
return h('div', { class: 'file-upload-wrapper' }, [
|
||||
// 拖拽上传区
|
||||
!uploadedInfo.value ? [
|
||||
h('div', {
|
||||
class: 'drag-area',
|
||||
onClick: () => {
|
||||
// 触发文件选择
|
||||
},
|
||||
}, [
|
||||
h('input', {
|
||||
type: 'file',
|
||||
accept: props.accept,
|
||||
style: { display: 'none' },
|
||||
ref: 'fileInput',
|
||||
onChange: (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileChange({ raw: file, name: file.name, size: file.size })
|
||||
}
|
||||
e.target.value = ''
|
||||
},
|
||||
}),
|
||||
h('div', {
|
||||
class: 'drag-content',
|
||||
onClick: (e) => {
|
||||
// 点击时触发隐藏的 file input
|
||||
e.currentTarget.previousElementSibling?.click()
|
||||
},
|
||||
}, [
|
||||
h(resolveComponent('el-icon'), { size: 48 }, () => h(UploadFilled)),
|
||||
h('div', { class: 'drag-text' }, '拖拽文件到此处或 '),
|
||||
h('em', null, '点击上传'),
|
||||
h('div', { class: 'drag-hint' },
|
||||
`支持 ${props.accept.replace(/\./g, '')} 格式,最大 ${props.maxSize / (1024 * 1024)}MB`
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
// 已选文件
|
||||
fileList.value.length > 0 ? h('div', { class: 'selected-file' }, [
|
||||
h('span', { class: 'selected-name' }, fileList.value[0].name || fileList.value[0].fileName),
|
||||
h('el-button', {
|
||||
type: 'danger',
|
||||
size: 'small',
|
||||
icon: h(Delete),
|
||||
circle: true,
|
||||
onClick: removeFile,
|
||||
}),
|
||||
]) : null,
|
||||
|
||||
// 上传按钮
|
||||
fileList.value.length > 0 ? h('div', { class: 'upload-actions' }, [
|
||||
h('el-button', {
|
||||
type: 'primary',
|
||||
loading: uploading.value,
|
||||
onClick: startUpload,
|
||||
}, { default: () => '开始上传' }),
|
||||
]) : null,
|
||||
] : null,
|
||||
|
||||
// 上传进度
|
||||
uploading.value ? h('div', { class: 'upload-progress' }, [
|
||||
h(resolveComponent('el-progress'), {
|
||||
percentage: uploadPercent.value,
|
||||
status: uploadPercent.value >= 100 ? 'success' : undefined,
|
||||
}),
|
||||
]) : null,
|
||||
|
||||
// 已上传文件信息
|
||||
uploadedInfo.value ? h('div', { class: 'uploaded-card' }, [
|
||||
h(resolveComponent('el-tag'), { type: 'success', size: 'small' }, () => '上传成功'),
|
||||
h('div', { class: 'uploaded-name' }, uploadedInfo.value.fileName),
|
||||
h('div', { class: 'uploaded-meta' }, [
|
||||
h('span', null, formatFileSize(uploadedInfo.value.fileSize)),
|
||||
uploadedInfo.value.md5 ? h('span', null, `MD5: ${uploadedInfo.value.md5.slice(0, 16)}...`) : null,
|
||||
].filter(Boolean)),
|
||||
h('el-button', {
|
||||
text: true,
|
||||
type: 'danger',
|
||||
size: 'small',
|
||||
onClick: removeFile,
|
||||
}, { default: () => '重新上传' }),
|
||||
]) : null,
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
components: { FileUploadArea },
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f6f8;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ==================== 顶部导航 ==================== */
|
||||
.upload-header {
|
||||
background: #1a3a5c;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 60px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-btn { color: rgba(255,255,255,0.85); white-space: nowrap; }
|
||||
.back-btn:hover { color: #c9a84c; }
|
||||
|
||||
.upload-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.upload-header-left {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-tag {
|
||||
background: rgba(201,168,76,0.2);
|
||||
color: #c9a84c;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== 内容 ==================== */
|
||||
.upload-content {
|
||||
max-width: 1000px;
|
||||
margin: 24px auto 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ==================== 截止时间 ==================== */
|
||||
.deadline-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.deadline-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.deadline-near { color: #f56c6c; }
|
||||
|
||||
.deadline-countdown {
|
||||
font-size: 18px;
|
||||
color: #1a3a5c;
|
||||
}
|
||||
|
||||
/* ==================== 上传卡片 ==================== */
|
||||
.upload-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.upload-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a3a5c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-card-desc {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ==================== 拖拽上传区 ==================== */
|
||||
.drag-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drag-area:hover {
|
||||
border-color: #1a3a5c;
|
||||
background: #f0f4f8;
|
||||
}
|
||||
|
||||
.drag-content {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.drag-text {
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.drag-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 已选文件 ==================== */
|
||||
.selected-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f0f4f8;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ==================== 上传成功信息 ==================== */
|
||||
.uploaded-card {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uploaded-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.uploaded-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ==================== 历史记录 ==================== */
|
||||
.history-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a3a5c;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
@media (max-width: 480px) {
|
||||
.upload-header { padding: 0 12px; }
|
||||
.deadline-card { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,52 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// Element Plus 按需样式模块清单(供下方 optimizeDeps.include 使用)。
|
||||
// 为什么不能用 glob:Vite 的 include 需要「运行时请求的 specifier 形式」(无扩展名,
|
||||
// 形如 element-plus/es/components/table/style/css),而 glob 匹配的是文件系统条目 ——
|
||||
// `style/css` 在磁盘上是目录而非文件,glob 会静默匹配 0 项(实测 '*/style/css' → 0 项);
|
||||
// 写成 '*/style/css.mjs' 虽能匹配 118 项,但其 key 与运行时 specifier 不一致,
|
||||
// Vite 仍会把每个组件判定为「新依赖」⇒ 等于没配。
|
||||
// 因此改为扫描目录动态生成:既不硬编码 118 项,也不会随 element-plus 升级失效。
|
||||
const EP_COMPONENTS_DIR = fileURLToPath(
|
||||
new URL('./node_modules/element-plus/es/components', import.meta.url)
|
||||
)
|
||||
const EP_STYLE_INCLUDES = (() => {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(EP_COMPONENTS_DIR)
|
||||
.filter((name) => fs.existsSync(path.join(EP_COMPONENTS_DIR, name, 'style', 'css.mjs')))
|
||||
.map((name) => `element-plus/es/components/${name}/style/css`)
|
||||
} catch {
|
||||
return [] // 依赖尚未安装时降级为空(vite build 不依赖此项)
|
||||
}
|
||||
})()
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// Element Plus 按需引入(替代 main.js 中的 app.use(ElementPlus) 全量注册)。
|
||||
// 构建期静态扫描模板,只把「实际用到的组件」及其样式打进产物:
|
||||
// <el-table> → element-plus/es/components/table + table/style/css
|
||||
// v-loading → ElLoadingDirective(resolver 的 directives 默认开启)
|
||||
// 注意两处边界(改前已核对):
|
||||
// ① 运行时字符串解析(h('el-button') / resolveComponent('el-icon'))不归它管
|
||||
// —— 已改为显式组件引用,见 views/Upload.vue;
|
||||
// ② 函数式 API(ElMessage/ElMessageBox/...)不会被注入样式,
|
||||
// —— 已在 main.js 里显式引入这四个样式。
|
||||
// dirs 置空 = 不额外扫描 src/components 目录,保持原有显式 import 行为不变,
|
||||
// 只让本插件负责 Element Plus 一个职责(最小改动)。
|
||||
Components({
|
||||
dts: 'src/components.d.ts',
|
||||
dirs: [],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
// 修复 Vite 内部 viteTransformMiddleware 调用 decodeURI 时遇到格式错误的 URL
|
||||
// 导致 "URI malformed" 错误的问题。在 Vite 中间件之前拦截并安全处理。
|
||||
{
|
||||
@@ -30,6 +71,18 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
// 依赖预构建:显式声明「按需引入」注入的 Element Plus 样式模块。
|
||||
// 必要性:unplugin-vue-components 是在 transform 阶段「运行时注入」style import 的,
|
||||
// 而 Vite 启动期的依赖扫描基于 index.html,看不到这些被注入的模块 ⇒ 运行中才发现
|
||||
// ⇒ 触发「二次预构建」(日志里的 new dependencies optimized + reloading)。
|
||||
// 后果不只是「访问新页面时整页重载」;在带 safe-delete 钩子的受限沙箱里,
|
||||
// 二次预构建清理临时目录会被钩子拦下,导致 dev server 进程直接退出。
|
||||
// 显式声明后首次预构建即完整,dev 启动一次成型。
|
||||
// ⚠️ 本项只作用于 dev(vite build 不做依赖预构建),不影响任何构建产物。
|
||||
optimizeDeps: {
|
||||
include: ['element-plus/es', ...EP_STYLE_INCLUDES],
|
||||
},
|
||||
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
|
||||
Reference in New Issue
Block a user