This commit is contained in:
2026-07-23 21:23:52 +08:00 Unverified
parent 6fd468d9e0
commit 25a63c67e6
60 changed files with 3122 additions and 4408 deletions
+2 -2
View File
@@ -98,14 +98,14 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
打开 <http://127.0.0.1:4173>。 打开 <http://127.0.0.1:4173>。
日常开发主页时,可以让 ASP.NET Core 继续监听 `4173`,另开一个终端启动 Vite 日常开发前端时,可以让 ASP.NET Core 继续监听 `4173`,另开一个终端启动 Vite
```powershell ```powershell
Set-Location .\src\Eis.Web\ClientApp Set-Location .\src\Eis.Web\ClientApp
npm run dev npm run dev
``` ```
然后打开 <http://127.0.0.1:5173>。Vite 会把 API、旧业务模块、CKEditor 和基础样式代理到 ASP.NET Core。当前采用渐进式迁移:Vue 已接管应用入口与公开首页;考生中心、管理后台招生学校端及其他公开子页继续通过兼容入口按需加载,业务行为保持不变。生产构建输出到 `src/Eis.Web/wwwroot/vue-app`,不得手工修改该目录中的文件。 然后打开 <http://127.0.0.1:5173>。Vite 会把 API 和 PDF 文书生成模块代理到 ASP.NET Core。前端已整体迁移为 Vue 3 单页应用,使用 Vue Router History 模式;公开服务、考生中心、管理后台招生学校端均使用独立的语义化 URL。生产构建输出到 `src/Eis.Web/wwwroot/vue-app`,不得手工修改该目录中的文件。
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。 账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
@@ -99,7 +99,7 @@ internal sealed partial class CandidateService
item["noticeVerificationCode"] = verificationCode; item["noticeVerificationCode"] = verificationCode;
item["noticeVerificationQr"] = verificationCode.Length == 0 item["noticeVerificationQr"] = verificationCode.Length == 0
? string.Empty ? string.Empty
: CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(verificationCode)}"); : CreateQrCodeDataUrl($"{verificationBaseUrl}/verify/{Uri.EscapeDataString(verificationCode)}");
item["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]); item["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]);
item["plans"] = new JsonArray(plans.Select(plan => (JsonNode)plan).ToArray()); item["plans"] = new JsonArray(plans.Select(plan => (JsonNode)plan).ToArray());
item["supplementEligible"] = supplementEligible; item["supplementEligible"] = supplementEligible;
@@ -57,7 +57,7 @@ internal sealed partial class CandidateService
.Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)); .Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt));
var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults); var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults);
summary["verificationCode"] = code; summary["verificationCode"] = code;
summary["verificationQr"] = CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(code)}"); summary["verificationQr"] = CreateQrCodeDataUrl($"{verificationBaseUrl}/verify/{Uri.EscapeDataString(code)}");
summaries.Add(summary); summaries.Add(summary);
} }
@@ -17,7 +17,7 @@
<PackageReference Include="StackExchange.Redis" /> <PackageReference Include="StackExchange.Redis" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="..\Eis.Web\wwwroot\js\data\china-regions.js" Link="Data\china-regions.js" /> <EmbeddedResource Include="Data\china-regions.js" />
<EmbeddedResource Include="Data\Schema.sqlite.sql" /> <EmbeddedResource Include="Data\Schema.sqlite.sql" />
<EmbeddedResource Include="Data\Schema.mysql.sql" /> <EmbeddedResource Include="Data\Schema.mysql.sql" />
<EmbeddedResource Include="Data\demo-seed-operations.json.gz" /> <EmbeddedResource Include="Data\demo-seed-operations.json.gz" />
-6
View File
@@ -6,15 +6,9 @@
<meta name="theme-color" content="#0d2d54" /> <meta name="theme-color" content="#0d2d54" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" /> <meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title> <title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/styles.css" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="modalRoot"></div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-icon"></span>
<div><strong>操作成功</strong><small>更改已保存</small></div>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+559 -43
View File
@@ -8,13 +8,77 @@
"name": "eis-web-client", "name": "eis-web-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"vue": "3.5.40" "vue": "3.5.40",
"vue-router": "5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue": "6.0.8",
"vite": "8.1.5" "vite": "8.1.5"
} }
}, },
"node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
"integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^8.0.0",
"@babel/types": "^8.0.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"@types/jsesc": "^2.5.0",
"jsesc": "^3.0.2"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/parser": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz",
"integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.4"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/types": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.4"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -65,7 +129,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -77,7 +140,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -88,24 +150,61 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"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.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"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.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -124,7 +223,7 @@
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -137,7 +236,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -154,7 +252,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -171,7 +268,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -188,7 +284,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -205,7 +300,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -222,7 +316,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -242,7 +335,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -262,7 +354,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -282,7 +373,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -302,7 +392,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -322,7 +411,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -342,7 +430,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -359,7 +446,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -378,7 +464,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -395,7 +480,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -409,20 +493,25 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.8", "version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
@@ -440,6 +529,33 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vue-macros/common": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz",
"integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "^3.5.22",
"ast-kit": "^2.1.2",
"local-pkg": "^1.1.2",
"magic-string-ast": "^1.0.2",
"unplugin-utils": "^0.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/vue-macros"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.40", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
@@ -490,6 +606,33 @@
"@vue/shared": "3.5.40" "@vue/shared": "3.5.40"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
"integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.1.5"
}
},
"node_modules/@vue/devtools-kit": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
"integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^8.1.5",
"birpc": "^2.6.1",
"hookable": "^5.5.3",
"perfect-debounce": "^2.0.0"
}
},
"node_modules/@vue/devtools-shared": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
"integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
"license": "MIT"
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.40", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
@@ -538,6 +681,81 @@
"integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
"integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-walker-scope": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz",
"integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@babel/types": "^7.29.0",
"ast-kit": "^2.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -548,7 +766,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -572,11 +790,16 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -594,7 +817,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -605,11 +827,41 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true, "devOptional": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -642,7 +894,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -663,7 +914,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -684,7 +934,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -705,7 +954,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -726,7 +974,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -747,7 +994,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -771,7 +1017,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -795,7 +1040,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -819,7 +1063,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -843,7 +1086,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -864,7 +1106,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -878,6 +1119,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"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/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -887,6 +1145,56 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magic-string-ast": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.19"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"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.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -905,6 +1213,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/nostics": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz",
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -915,7 +1241,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -924,6 +1249,17 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.22", "version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
@@ -952,11 +1288,40 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.139.0", "@oxc-project/types": "=0.139.0",
@@ -986,6 +1351,12 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5" "@rolldown/binding-win32-x64-msvc": "1.1.5"
} }
}, },
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -999,7 +1370,6 @@
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -1016,15 +1386,90 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.4",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@farmfe/core": "*",
"@rspack/core": "*",
"bun-types-no-globals": "*",
"esbuild": "*",
"rolldown": "*",
"rollup": "*",
"unloader": "*",
"vite": "*",
"webpack": "*"
},
"peerDependenciesMeta": {
"@farmfe/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.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
"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/vite": { "node_modules/vite": {
"version": "8.1.5", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
@@ -1118,6 +1563,77 @@
"optional": true "optional": true
} }
} }
},
"node_modules/vue-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz",
"integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^8.0.0",
"@vue-macros/common": "^3.1.3",
"@vue/devtools-api": "^8.1.5",
"ast-walker-scope": "^0.9.0",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
"local-pkg": "^1.2.1",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"muggle-string": "^0.4.1",
"nostics": "^1.1.4",
"pathe": "^2.0.3",
"picomatch": "^4.0.5",
"scule": "^1.3.0",
"tinyglobby": "^0.2.17",
"unplugin": "^3.3.0",
"unplugin-utils": "^0.3.2",
"yaml": "^2.9.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@pinia/colada": ">=0.21.2",
"@vue/compiler-sfc": "^3.5.34 || ^4.0.0",
"pinia": "^3.0.4 || ^4.0.2",
"vite": "^7.3.0 || ^8.0.0",
"vue": "^3.5.34 || ^4.0.0"
},
"peerDependenciesMeta": {
"@pinia/colada": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"pinia": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
} }
} }
} }
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "3.5.40" "vue": "3.5.40",
"vue-router": "5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue": "6.0.8",
+25 -55
View File
@@ -1,62 +1,32 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onBeforeUnmount, onMounted } from 'vue';
import HomePage from '@/components/HomePage.vue'; import { RouterView } from 'vue-router';
import { api } from '@/lib/api'; import { useRoute, useRouter } from 'vue-router';
import { uiStore } from '@/stores/ui';
import { sessionStore } from '@/stores/session';
const loading = ref(true); const router = useRouter();
const error = ref(''); const route = useRoute();
const publicData = ref({ organization: {}, notices: [], exams: [], stats: {} }); function sessionExpired() {
const session = ref({ user: null, profile: null, permissions: [] }); sessionStore.clearSession();
uiStore.notify('登录状态已失效', '请重新登录后继续办理', 'warning');
async function load() { if (route.path !== '/auth/login') router.replace({ path: '/auth/login', query: { redirect: route.fullPath } });
loading.value = true;
error.value = '';
try {
const [home, currentSession] = await Promise.all([
api('/api/public/home'),
api('/api/auth/me').catch(requestError => {
if (requestError.status === 401) return { user: null, profile: null, permissions: [] };
throw requestError;
})
]);
publicData.value = home;
session.value = currentSession;
} catch (requestError) {
error.value = requestError.message || '首页服务暂时不可用,请稍后重试。';
} finally {
loading.value = false;
}
} }
onMounted(() => window.addEventListener('eis:session-expired', sessionExpired));
async function logout() { onBeforeUnmount(() => window.removeEventListener('eis:session-expired', sessionExpired));
try {
await api('/api/auth/logout', { method: 'POST' });
session.value = { user: null, profile: null, permissions: [] };
await load();
} catch (requestError) {
error.value = requestError.message || '退出未完成,请稍后重试。';
}
}
onMounted(load);
</script> </script>
<template> <template>
<div v-if="loading" class="hz-loading" role="status" aria-live="polite"> <RouterView />
<span class="hz-loading__seal" aria-hidden="true"></span> <Transition name="toast">
<strong>正在接入考试公共服务</strong> <aside v-if="uiStore.state.toast" :class="['app-toast', `is-${uiStore.state.toast.tone}`]" role="status">
<small>请稍候</small> <strong>{{ uiStore.state.toast.title }}</strong>
</div> <span>{{ uiStore.state.toast.message }}</span>
<main v-else-if="error" class="hz-failure"> </aside>
<span>服务提示</span> </Transition>
<h1>首页暂时无法加载</h1> <Teleport to="body">
<p>{{ error }}</p> <div v-if="uiStore.state.modal" class="app-modal-backdrop" @click.self="uiStore.closeModal()">
<button type="button" @click="load">重新加载</button> <component :is="uiStore.state.modal.component" v-bind="uiStore.state.modal.props" @close="uiStore.closeModal()" />
</main> </div>
<HomePage </Teleport>
v-else
:public-data="publicData"
:session="session"
@logout="logout"
/>
</template> </template>
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { dateRange, formatDate, registrationLabel, roleHome } from '@/lib/format'; import { dateRange, formatDate, registrationLabel, roleHome } from '@/lib/format';
const props = defineProps({ const props = defineProps({
@@ -9,6 +10,7 @@ const props = defineProps({
const emit = defineEmits(['logout']); const emit = defineEmits(['logout']);
const mobileOpen = ref(false); const mobileOpen = ref(false);
const router = useRouter();
const notices = computed(() => props.publicData.notices || []); const notices = computed(() => props.publicData.notices || []);
const exams = computed(() => props.publicData.exams || []); const exams = computed(() => props.publicData.exams || []);
@@ -28,7 +30,7 @@ const portalLabel = computed(() => {
const primaryRoute = computed(() => { const primaryRoute = computed(() => {
if (user.value) return roleHome(user.value); if (user.value) return roleHome(user.value);
return props.publicData.selfRegistrationEnabled ? 'register' : 'login'; return props.publicData.selfRegistrationEnabled ? '/auth/register' : '/auth/login';
}); });
const primaryLabel = computed(() => { const primaryLabel = computed(() => {
@@ -38,7 +40,7 @@ const primaryLabel = computed(() => {
function go(route) { function go(route) {
mobileOpen.value = false; mobileOpen.value = false;
location.hash = route; router.push(route);
} }
function scrollToSection(id) { function scrollToSection(id) {
@@ -47,7 +49,7 @@ function scrollToSection(id) {
} }
function openExam() { function openExam() {
go(user.value?.role === 'candidate' ? 'candidate/exams' : 'login'); go(user.value?.role === 'candidate' ? '/candidate/exams' : '/auth/login');
} }
function count(value) { function count(value) {
@@ -64,14 +66,14 @@ function count(value) {
<p><span aria-hidden="true"></span>考试信息公共服务平台</p> <p><span aria-hidden="true"></span>考试信息公共服务平台</p>
<div> <div>
<span v-if="organization.phone">咨询电话{{ organization.phone }}</span> <span v-if="organization.phone">咨询电话{{ organization.phone }}</span>
<button type="button" @click="go('verify')">文书防伪查询</button> <button type="button" @click="go('/verify')">文书防伪查询</button>
</div> </div>
</div> </div>
</div> </div>
<header class="hz-header"> <header class="hz-header">
<div class="hz-container hz-header__inner"> <div class="hz-container hz-header__inner">
<button class="hz-brand" type="button" aria-label="返回首页" @click="go('home')"> <button class="hz-brand" type="button" aria-label="返回首页" @click="go('/')">
<span class="hz-brand__seal" aria-hidden="true"></span> <span class="hz-brand__seal" aria-hidden="true"></span>
<span class="hz-brand__copy"> <span class="hz-brand__copy">
<strong>衡准考试服务</strong> <strong>衡准考试服务</strong>
@@ -80,10 +82,10 @@ function count(value) {
</button> </button>
<nav :class="['hz-nav', { 'is-open': mobileOpen }]" aria-label="主要导航"> <nav :class="['hz-nav', { 'is-open': mobileOpen }]" aria-label="主要导航">
<button type="button" class="is-current" @click="go('home')">首页</button> <button type="button" class="is-current" @click="go('/')">首页</button>
<button type="button" @click="scrollToSection('hz-exams')">考试报名</button> <button type="button" @click="scrollToSection('hz-exams')">考试报名</button>
<button type="button" @click="go('notices')">通知公告</button> <button type="button" @click="go('/announcements')">通知公告</button>
<button type="button" @click="go('verify')">防伪查询</button> <button type="button" @click="go('/verify')">防伪查询</button>
<button type="button" @click="scrollToSection('hz-guide')">办事指南</button> <button type="button" @click="scrollToSection('hz-guide')">办事指南</button>
</nav> </nav>
@@ -106,7 +108,7 @@ function count(value) {
<main id="hz-main"> <main id="hz-main">
<section class="hz-hero"> <section class="hz-hero">
<div class="hz-container"> <div class="hz-container">
<button v-if="topNotice" class="hz-latest" type="button" @click="go(`notice/${topNotice.id}`)"> <button v-if="topNotice" class="hz-latest" type="button" @click="go(`/announcements/${topNotice.id}`)">
<span>最新发布</span> <span>最新发布</span>
<strong>{{ topNotice.title }}</strong> <strong>{{ topNotice.title }}</strong>
<time>{{ formatDate(topNotice.publishAt) }}</time> <time>{{ formatDate(topNotice.publishAt) }}</time>
@@ -193,13 +195,13 @@ function count(value) {
<small>查看开放考试报名日期与科目安排</small> <small>查看开放考试报名日期与科目安排</small>
<i>查看考试 </i> <i>查看考试 </i>
</button> </button>
<button type="button" @click="go(user?.role === 'candidate' ? 'candidate/results' : 'login')"> <button type="button" @click="go(user?.role === 'candidate' ? '/candidate/results' : '/auth/login')">
<span class="hz-service-grid__index">03</span> <span class="hz-service-grid__index">03</span>
<strong>成绩与准考证</strong> <strong>成绩与准考证</strong>
<small>下载准考证查询已正式发布的成绩</small> <small>下载准考证查询已正式发布的成绩</small>
<i>办理查询 </i> <i>办理查询 </i>
</button> </button>
<button type="button" @click="go('verify')"> <button type="button" @click="go('/verify')">
<span class="hz-service-grid__index">04</span> <span class="hz-service-grid__index">04</span>
<strong>文书防伪核验</strong> <strong>文书防伪核验</strong>
<small>核对成绩单录取通知书签发记录</small> <small>核对成绩单录取通知书签发记录</small>
@@ -214,7 +216,7 @@ function count(value) {
<div class="hz-notices"> <div class="hz-notices">
<div class="hz-section-heading"> <div class="hz-section-heading">
<div><p>PUBLIC INFORMATION</p><h2>通知公告</h2></div> <div><p>PUBLIC INFORMATION</p><h2>通知公告</h2></div>
<button type="button" @click="go('notices')">查看全部 </button> <button type="button" @click="go('/announcements')">查看全部 </button>
</div> </div>
<article v-if="topNotice" class="hz-featured-notice"> <article v-if="topNotice" class="hz-featured-notice">
<div> <div>
@@ -223,11 +225,11 @@ function count(value) {
</div> </div>
<h3>{{ topNotice.title }}</h3> <h3>{{ topNotice.title }}</h3>
<p>{{ topNotice.summary || '请进入公告正文查看完整内容和办理要求。' }}</p> <p>{{ topNotice.summary || '请进入公告正文查看完整内容和办理要求。' }}</p>
<button type="button" @click="go(`notice/${topNotice.id}`)">阅读全文 <span></span></button> <button type="button" @click="go(`/announcements/${topNotice.id}`)">阅读全文 <span></span></button>
</article> </article>
<div v-else class="hz-empty">当前暂无通知公告</div> <div v-else class="hz-empty">当前暂无通知公告</div>
<div class="hz-notice-list"> <div class="hz-notice-list">
<button v-for="notice in notices.slice(1, 5)" :key="notice.id" type="button" @click="go(`notice/${notice.id}`)"> <button v-for="notice in notices.slice(1, 5)" :key="notice.id" type="button" @click="go(`/announcements/${notice.id}`)">
<time><strong>{{ String(new Date(notice.publishAt).getDate()).padStart(2, '0') }}</strong><span>{{ new Date(notice.publishAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit' }).replace('/', '.') }}</span></time> <time><strong>{{ String(new Date(notice.publishAt).getDate()).padStart(2, '0') }}</strong><span>{{ new Date(notice.publishAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit' }).replace('/', '.') }}</span></time>
<span><em>{{ notice.category || '通知' }}</em><strong>{{ notice.title }}</strong></span> <span><em>{{ notice.category || '通知' }}</em><strong>{{ notice.title }}</strong></span>
<i></i> <i></i>
@@ -245,7 +247,7 @@ function count(value) {
<section> <section>
<strong>公开信息说明</strong> <strong>公开信息说明</strong>
<p>考试安排录取公示及其他重要事项以平台通知公告栏目正式发布内容为准</p> <p>考试安排录取公示及其他重要事项以平台通知公告栏目正式发布内容为准</p>
<button type="button" @click="go('notices')">进入公开信息目录</button> <button type="button" @click="go('/announcements')">进入公开信息目录</button>
</section> </section>
</aside> </aside>
</div> </div>
@@ -309,9 +311,9 @@ function count(value) {
<div v-if="organization.address"><dt>联系地址</dt><dd>{{ organization.address }}</dd></div> <div v-if="organization.address"><dt>联系地址</dt><dd>{{ organization.address }}</dd></div>
</dl> </dl>
<div class="hz-footer__links"> <div class="hz-footer__links">
<button type="button" @click="go('notices')">通知公告</button> <button type="button" @click="go('/announcements')">通知公告</button>
<button type="button" @click="go('verify')">文书核验</button> <button type="button" @click="go('/verify')">文书核验</button>
<button type="button" @click="go('login')">服务登录</button> <button type="button" @click="go('/auth/login')">服务登录</button>
</div> </div>
</div> </div>
<div class="hz-footer__legal"> <div class="hz-footer__legal">
@@ -0,0 +1,119 @@
<script setup>
import { reactive, ref } from 'vue';
import { api } from '@/lib/api';
import { sessionStore } from '@/stores/session';
import { uiStore } from '@/stores/ui';
import StatusBadge from './StatusBadge.vue';
const props = defineProps({ status: { type: Object, default: () => ({}) } });
const emit = defineEmits(['updated']);
const busy = ref(false);
const error = ref('');
const setup = ref(null);
const recoveryCodes = ref([]);
const password = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const setupPassword = ref('');
const enableCode = ref('');
const protectedAction = reactive({ currentPassword: '', code: '' });
async function run(action) {
busy.value = true;
error.value = '';
try { await action(); }
catch (requestError) { error.value = requestError.message; }
finally { busy.value = false; }
}
function changePassword() {
return run(async () => {
if (password.newPassword !== password.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body: password });
Object.assign(password, { currentPassword: '', newPassword: '', confirmPassword: '' });
uiStore.notify('密码修改成功', '下次登录请使用新密码');
});
}
function beginSetup() {
return run(async () => {
setup.value = await api('/api/auth/totp/setup', { method: 'POST', body: { currentPassword: setupPassword.value } });
setupPassword.value = '';
});
}
function enableTotp() {
return run(async () => {
const response = await api('/api/auth/totp/enable', { method: 'POST', body: { code: enableCode.value } });
recoveryCodes.value = response.recoveryCodes || [];
setup.value = null;
enableCode.value = '';
await sessionStore.refreshSession();
emit('updated');
uiStore.notify('二次验证已开启', '请立即保存恢复码');
});
}
function regenerateCodes() {
return run(async () => {
const response = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: protectedAction });
recoveryCodes.value = response.recoveryCodes || [];
Object.assign(protectedAction, { currentPassword: '', code: '' });
emit('updated');
});
}
function disableTotp() {
return run(async () => {
await api('/api/auth/totp/disable', { method: 'POST', body: protectedAction });
Object.assign(protectedAction, { currentPassword: '', code: '' });
recoveryCodes.value = [];
await sessionStore.refreshSession();
emit('updated');
uiStore.notify('二次验证已关闭', '账户现在仅使用密码登录');
});
}
async function copyCodes() {
await navigator.clipboard.writeText(recoveryCodes.value.join('\n'));
uiStore.notify('恢复码已复制', '请保存到安全的位置');
}
</script>
<template>
<div class="security-stack">
<div v-if="error" class="form-error">{{ error }}</div>
<section v-if="recoveryCodes.length" class="recovery-code-panel">
<div><p>RECOVERY CODES</p><h2>立即保存恢复码</h2><span>每个恢复码只能使用一次关闭页面后系统不会再次展示本组代码</span></div>
<div class="recovery-code-grid"><code v-for="code in recoveryCodes" :key="code">{{ code }}</code></div>
<button class="app-button app-button--primary" type="button" @click="copyCodes">复制全部恢复码</button>
</section>
<form class="business-form security-card" @submit.prevent="changePassword">
<header><div><p>LOGIN PASSWORD</p><h2>修改登录密码</h2></div><StatusBadge value="active" /></header>
<span>账号{{ sessionStore.state.user?.username || sessionStore.state.user?.candidateNumber }}新密码至少 8 并应与当前密码不同</span>
<label><span>当前密码</span><input v-model="password.currentPassword" type="password" autocomplete="current-password" required></label>
<label><span>新密码</span><input v-model="password.newPassword" type="password" autocomplete="new-password" minlength="8" required></label>
<label><span>再次输入新密码</span><input v-model="password.confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label>
<button class="app-button app-button--primary" :disabled="busy">保存新密码</button>
</form>
<section class="business-form security-card">
<header><div><p>TWO-STEP VERIFICATION</p><h2>TOTP 二次验证</h2></div><StatusBadge :value="status.enabled ? 'active' : 'disabled'" /></header>
<template v-if="!status.enabled && !setup">
<span>使用验证器应用生成的动态验证码为账号增加独立于密码的第二层保护</span>
<form class="inline-security-form" @submit.prevent="beginSetup"><label><span>确认当前密码</span><input v-model="setupPassword" type="password" autocomplete="current-password" required></label><button class="app-button app-button--primary" :disabled="busy">开始绑定验证器</button></form>
</template>
<template v-else-if="setup">
<div class="totp-setup-grid"><img :src="setup.qrCode" alt="TOTP 绑定二维码" width="220" height="220"><div><span>无法扫码时手动输入密钥</span><code>{{ setup.secret?.match(/.{1,4}/g)?.join(' ') || setup.secret }}</code><small>基于时间 · 6 · 30 秒更新</small></div></div>
<form class="inline-security-form" @submit.prevent="enableTotp"><label><span>验证器中的 6 位验证码</span><input v-model="enableCode" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></label><button class="app-button app-button--primary" :disabled="busy">验证并启用</button></form>
</template>
<template v-else>
<span>二次验证正在保护此账号当前剩余 {{ status.recoveryCodesRemaining }} 个恢复码</span>
<div class="security-protected-actions">
<label><span>当前密码</span><input v-model="protectedAction.currentPassword" type="password" autocomplete="current-password"></label>
<label><span>动态验证码或恢复码</span><input v-model="protectedAction.code" autocomplete="one-time-code"></label>
<div><button class="app-button" type="button" :disabled="busy" @click="regenerateCodes">重新生成恢复码</button><button class="app-button app-button--danger" type="button" :disabled="busy" @click="disableTotp">关闭二次验证</button></div>
</div>
</template>
</section>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup>
defineProps({
loading: Boolean,
error: { type: String, default: '' },
empty: Boolean,
emptyTitle: { type: String, default: '暂无数据' },
emptyText: { type: String, default: '当前没有可显示的业务记录。' }
});
defineEmits(['retry']);
</script>
<template>
<div v-if="loading" class="page-state page-state--loading" role="status"><i></i><strong>正在读取数据</strong></div>
<div v-else-if="error" class="page-state page-state--error"><span>!</span><strong>页面加载失败</strong><p>{{ error }}</p><button type="button" @click="$emit('retry')">重新加载</button></div>
<div v-else-if="empty" class="page-state page-state--empty"><strong>{{ emptyTitle }}</strong><p>{{ emptyText }}</p></div>
<slot v-else />
</template>
@@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue';
import { chinaRegions } from '@/data/china-regions';
import { specialtyCatalog, specialtyTypesFor } from '@/data/specialties';
const props = defineProps({ form: { type: Object, required: true }, schools: { type: Array, default: () => [] }, classes: { type: Array, default: () => [] } });
const specialtyTypes = computed(() => specialtyTypesFor(props.form.specialtyCategory));
const cities = computed(() => chinaRegions.find(item => item.code === props.form.provinceCode)?.cities || []);
const districts = computed(() => cities.value.find(item => item.code === props.form.cityCode)?.districts || []);
function resetSpecialtyType() { props.form.specialtyType = ''; }
function resetCity() { props.form.cityCode = ''; props.form.districtCode = ''; }
function resetDistrict() { props.form.districtCode = ''; }
</script>
<template>
<div class="profile-fields">
<h2>身份信息</h2>
<div class="form-grid">
<label><span>考生姓名 *</span><input v-model="form.name" required></label>
<label><span>性别 *</span><select v-model="form.gender" required><option value="">请选择</option><option></option><option></option></select></label>
<label><span>证件号码 *</span><input v-model="form.idNumber" required></label>
<label><span>出生日期</span><input v-model="form.birthDate" type="date"></label>
<label><span>籍贯 *</span><input v-model="form.nativePlace" required></label>
<label><span>民族</span><input v-model="form.ethnicity"></label>
</div>
<h2>学校与班级</h2>
<div class="form-grid">
<label><span>就读学校 *</span><select v-model="form.schoolId" required @change="form.classId = ''"><option value="">请选择</option><option v-for="school in schools" :key="school.id" :value="school.id">{{ school.name }}</option></select></label>
<label><span>班级 *</span><select v-model="form.classId" required><option value="">请选择</option><option v-for="item in classes" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
</div>
<h2>家庭与联系信息</h2>
<div class="form-grid">
<label><span>所在省份 *</span><select v-model="form.provinceCode" required @change="resetCity"><option value="">请选择</option><option v-for="item in chinaRegions" :key="item.code" :value="item.code">{{ item.name }}</option></select></label>
<label><span>所在城市 *</span><select v-model="form.cityCode" required :disabled="!form.provinceCode" @change="resetDistrict"><option value="">请选择</option><option v-for="item in cities" :key="item.code" :value="item.code">{{ item.name }}</option></select></label>
<label><span>所在区县 *</span><select v-model="form.districtCode" required :disabled="!form.cityCode"><option value="">请选择</option><option v-for="item in districts" :key="item.code" :value="item.code">{{ item.name }}</option></select></label>
<label><span>手机号 *</span><input v-model="form.phone" required></label>
<label><span>电子邮箱 *</span><input v-model="form.email" type="email" required></label>
<label class="wide"><span>家庭住址 *</span><input v-model="form.address" required></label>
<label><span>邮政编码</span><input v-model="form.postalCode"></label>
<label><span>监护人姓名</span><input v-model="form.guardianName"></label>
<label><span>监护人电话</span><input v-model="form.guardianPhone"></label>
<label><span>紧急联系人</span><input v-model="form.emergencyContact"></label>
<label><span>紧急联系电话</span><input v-model="form.emergencyPhone"></label>
</div>
<h2>招生资格</h2>
<div class="form-grid">
<label><span>特长生大类</span><select v-model="form.specialtyCategory" @change="resetSpecialtyType"><option value="">无特长资格</option><option v-for="item in specialtyCatalog" :key="item.code" :value="item.code">{{ item.name }}</option></select></label>
<label><span>特长项目</span><select v-model="form.specialtyType" :disabled="!form.specialtyCategory"><option value="">请选择</option><option v-for="item in specialtyTypes" :key="item[0]" :value="item[0]">{{ item[1] }}</option></select></label>
<label><span>特长证明编号</span><input v-model="form.specialtyCertificate"></label>
<label class="wide"><span>政策资格说明</span><input v-model="form.policyEligibility"></label>
</div>
</div>
</template>
@@ -0,0 +1,51 @@
<script setup>
import { computed, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { sessionStore } from '@/stores/session';
const router = useRouter();
const menuOpen = ref(false);
const organization = computed(() => sessionStore.state.publicData.organization || {});
async function logout() {
await sessionStore.logout();
await router.push('/');
}
</script>
<template>
<div class="public-frame">
<div class="public-frame__utility">
<div class="app-container">
<span>考试信息公共服务平台</span>
<span v-if="organization.phone">咨询电话{{ organization.phone }}</span>
</div>
</div>
<header class="public-frame__header">
<div class="app-container">
<RouterLink class="app-brand" to="/" @click="menuOpen = false">
<span></span>
<div><strong>衡准考试服务</strong><small>EXAMINATION INFORMATION SERVICE</small></div>
</RouterLink>
<nav :class="{ 'is-open': menuOpen }" aria-label="公共服务导航">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/announcements">通知公告</RouterLink>
<RouterLink to="/verify">文书核验</RouterLink>
</nav>
<div class="public-frame__actions">
<RouterLink v-if="!sessionStore.state.user" class="app-button app-button--primary" to="/auth/login">登录</RouterLink>
<RouterLink v-else class="app-button app-button--primary" :to="sessionStore.homeFor()">进入业务中心</RouterLink>
<button v-if="sessionStore.state.user" class="app-link-button" type="button" @click="logout">退出</button>
<button class="public-frame__menu" type="button" :aria-expanded="menuOpen" aria-label="打开导航" @click="menuOpen = !menuOpen"></button>
</div>
</div>
</header>
<main class="public-frame__main"><slot /></main>
<footer class="public-frame__footer">
<div class="app-container">
<div><strong>{{ organization.name || '考试信息管理机构' }}</strong><span>{{ organization.address || '统一考试公共服务平台' }}</span></div>
<span>公开信息以本平台正式发布内容为准</span>
</div>
</footer>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({ value: { type: [String, Boolean, Number], default: '' } });
const labels = {
approved: '已通过', pending: '待处理', rejected: '已退回', draft: '草稿', published: '已发布',
open: '开放中', upcoming: '即将开放', closed: '已结束', archived: '已归档', completed: '已完成',
active: '正常', disabled: '已停用', unpaid: '未缴费', paid: '已缴费', final: '正式录取',
school_review: '学校审核', withdrawal_pending: '退档待审', reported: '已报到', not_reported: '未报到'
};
</script>
<template>
<span :class="['status-badge', `is-${String(props.value).replaceAll('_', '-')}`]">{{ labels[props.value] || props.value || '—' }}</span>
</template>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
export const specialtyCatalog = [
{ code: 'sports', name: '体育', types: [['track_field', '田径'], ['basketball', '篮球'], ['football', '足球'], ['volleyball', '排球'], ['table_tennis', '乒乓球'], ['badminton', '羽毛球'], ['swimming', '游泳'], ['martial_arts', '武术'], ['aerobics_cheer', '健美操与啦啦操']] },
{ code: 'arts', name: '艺术', types: [['vocal_music', '声乐'], ['instrumental_music', '器乐'], ['dance', '舞蹈'], ['fine_arts', '美术'], ['calligraphy', '书法'], ['drama_broadcasting', '戏剧与播音']] }
];
export function specialtyTypesFor(category) {
return specialtyCatalog.find(item => item.code === category)?.types || [];
}
@@ -0,0 +1,57 @@
<script setup>
import { computed, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { roleNavigation, routeFor } from '@/lib/navigation';
import { sessionStore } from '@/stores/session';
const props = defineProps({
role: { type: String, required: true },
page: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, default: '' }
});
const router = useRouter();
const open = ref(false);
const user = computed(() => sessionStore.state.user || {});
const nav = computed(() => roleNavigation(props.role, user.value.adminLevel));
const groups = computed(() => [...new Set(nav.value.map(item => item.group))]);
const roleTitle = computed(() => props.role === 'candidate' ? '考生中心' : props.role === 'admission_school' ? '招生学校工作台' : '考试管理后台');
const scope = computed(() => props.role === 'candidate' ? '仅本人数据' : props.role === 'admission_school' ? '仅本校招生数据' : sessionStore.state.scopeLabel || '当前权限范围');
async function logout() {
await sessionStore.logout();
await router.replace('/auth/login');
}
</script>
<template>
<div class="portal-shell">
<aside :class="['portal-shell__sidebar', { 'is-open': open }]">
<RouterLink class="portal-shell__brand" to="/"><span></span><div><strong>衡准考试服务</strong><small>OPERATIONS CONSOLE</small></div></RouterLink>
<button class="portal-shell__close" type="button" aria-label="关闭菜单" @click="open = false">×</button>
<p class="portal-shell__role">{{ roleTitle }}</p>
<nav aria-label="业务导航">
<section v-for="group in groups" :key="group">
<strong>{{ group }}</strong>
<RouterLink v-for="item in nav.filter(row => row.group === group)" :key="item.page" :to="routeFor(role, item.page)" @click="open = false">
<span>{{ item.label.slice(0, 1) }}</span>{{ item.label }}
</RouterLink>
</section>
</nav>
<div class="portal-shell__scope"><span>当前数据范围</span><strong>{{ scope }}</strong><small>权限由服务端同步校验</small></div>
</aside>
<div v-if="open" class="portal-shell__scrim" @click="open = false"></div>
<main class="portal-shell__main">
<header class="portal-shell__topbar">
<button type="button" aria-label="打开菜单" @click="open = true"></button>
<div><span>{{ roleTitle }}</span><b>/</b><strong>{{ title }}</strong></div>
<div class="portal-shell__user"><i>{{ String(user.displayName || '用').slice(0, 1) }}</i><span><strong>{{ user.displayName || user.username }}</strong><small>{{ scope }}</small></span><button type="button" title="退出登录" @click="logout">退出</button></div>
</header>
<section class="portal-shell__content">
<header class="portal-page-heading"><div><p>{{ role === 'candidate' ? 'CANDIDATE SERVICE' : role === 'admission_school' ? 'SCHOOL ADMISSION' : 'EXAM OPERATIONS' }}</p><h1>{{ title }}</h1><span>{{ description }}</span></div><slot name="actions" /></header>
<slot />
</section>
</main>
</div>
</template>
+3
View File
@@ -19,6 +19,9 @@ async function request(path, options = {}) {
if (!response.ok) { if (!response.ok) {
const error = new Error(data?.message || '操作未完成,请稍后重试'); const error = new Error(data?.message || '操作未完成,请稍后重试');
error.status = response.status; error.status = response.status;
if (response.status === 401 && !['/api/auth/me','/api/auth/login','/api/auth/login/totp'].includes(path)) {
window.dispatchEvent(new CustomEvent('eis:session-expired'));
}
throw error; throw error;
} }
return data; return data;
+19 -5
View File
@@ -31,9 +31,23 @@ export function registrationLabel(state) {
}[state] || '已发布'; }[state] || '已发布';
} }
export function roleHome(user) { export function money(value) {
if (!user) return 'login'; return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(Number(value || 0));
if (user.role === 'candidate') return 'candidate/dashboard'; }
if (user.role === 'admission_school') return 'admission_school/dashboard';
return 'admin/dashboard'; export function passPolicyText(exam = {}) {
const labels = {
fixed_score: `固定总分线 ${exam.passValue ?? '—'}`,
rank_percent: `总成绩排名前 ${exam.passValue ?? '—'}%`,
subject_scores: '所有单科均达线',
none: '不判定合格'
};
return labels[exam.passPolicy] || '按考试规则判定';
}
export function roleHome(user) {
if (!user) return '/auth/login';
if (user.role === 'candidate') return '/candidate/dashboard';
if (user.role === 'admission_school') return '/admission/dashboard';
return '/admin/dashboard';
} }
@@ -0,0 +1,60 @@
export const candidateNavigation = [
{ page: 'dashboard', label: '总览', group: '个人总览' },
{ page: 'profile', label: '个人资料', group: '账户与档案' },
{ page: 'security', label: '账户安全', group: '账户与档案' },
{ page: 'exams', label: '考试报名', group: '考试服务' },
{ page: 'registrations', label: '我的报名', group: '考试服务' },
{ page: 'admit', label: '准考证', group: '考试服务' },
{ page: 'results', label: '成绩查询', group: '考试服务' },
{ page: 'admissions', label: '志愿与录取', group: '招生录取' },
{ page: 'notices', label: '通知公告', group: '招生录取' }
];
const adminBase = [
{ page: 'dashboard', label: '工作台', group: '运行总览', levels: ['super', 'school', 'class'] },
{ page: 'schools', label: '学校管理', group: '组织与账户', levels: ['super'] },
{ page: 'organization', label: '本校组织', group: '组织与账户', levels: ['school'] },
{ page: 'admins', label: '管理员', group: '组织与账户', levels: ['super'] },
{ page: 'account-batches', label: '批量建号', group: '组织与账户', levels: ['school'] },
{ page: 'candidates', label: '考生信息', group: '报名考务', levels: ['super', 'school', 'class'] },
{ page: 'indicator-qualifications', label: '指标资格确认', group: '招生录取', levels: ['school'] },
{ page: 'registrations', label: '报名审核', group: '报名考务', levels: ['super', 'school', 'class'] },
{ page: 'payments', label: '缴费名单', group: '报名考务', levels: ['super', 'school', 'class'] },
{ page: 'admit', label: '准考证', group: '报名考务', levels: ['super', 'school', 'class'] },
{ page: 'exams', label: '考试与科目', group: '考试与成绩', levels: ['super'] },
{ page: 'results', label: '成绩管理', group: '考试与成绩', levels: ['super', 'school', 'class'] },
{ page: 'admission-settings', label: '录取设置', group: '招生录取', levels: ['super'] },
{ page: 'admission-accounts', label: '招生账户', group: '招生录取', levels: ['super'] },
{ page: 'admission-plans', label: '招生计划', group: '招生录取', levels: ['super'] },
{ page: 'admission-reporting', label: '报到与补录', group: '招生录取', levels: ['super'] },
{ page: 'admission-supervision', label: '投档监督', group: '招生录取', levels: ['super'] },
{ page: 'notices', label: '通知发布', group: '公开信息', levels: ['super'] },
{ page: 'centers', label: '考场信息', group: '场所与流程', levels: ['super', 'school'] },
{ page: 'flows', label: '流程中心', group: '场所与流程', levels: ['super', 'school', 'class'] },
{ page: 'flow-design', label: '流程设计', group: '系统配置', levels: ['super'] },
{ page: 'number-rules', label: '报名号规则', group: '系统配置', levels: ['super'] },
{ page: 'security', label: '账户安全', group: '系统配置', levels: ['super', 'school', 'class'] }
];
export function adminNavigation(level = 'super') {
return adminBase.filter(item => item.levels.includes(level));
}
export const admissionNavigation = [
{ page: 'dashboard', label: '工作台', group: '总览' },
{ page: 'plans', label: '招生计划', group: '招生业务' },
{ page: 'placements', label: '投档审核', group: '招生业务' },
{ page: 'reporting', label: '考生报到', group: '招生业务' },
{ page: 'notice-template', label: '通知书模板', group: '文书中心' }
];
export function roleNavigation(role, level) {
if (role === 'candidate') return candidateNavigation;
if (role === 'admission_school') return admissionNavigation;
return adminNavigation(level);
}
export function routeFor(role, page) {
if (role === 'admission_school') return `/admission/${page}`;
return `/${role}/${page}`;
}
@@ -0,0 +1,32 @@
export function buildPublicDocuments(home, data = {}) {
const ordinary = (home.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({
...item,
documentId: String(item.id),
documentType: 'notice',
subtype: item.category || '通知公告',
publishedAt: item.publishAt
}));
const plans = (data.plans || []).map(item => ({
...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划',
title: `${item.examName} · ${item.schoolName}招生计划公示`,
summary: `${item.rows?.reduce((sum, row) => sum + Number(row.quota || 0), 0) || 0} 个招生名额。`
}));
const qualifications = (data.qualifications || []).map(item => ({
...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格',
title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `公开 ${item.rows?.length || 0} 名考生的指标分配资格。`
}));
const admissions = (data.admissions || []).map(item => ({
...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `${item.round} 轮录取名单` : '最终录取名单',
title: item.title || `${item.examName}最终录取名单`, summary: `${item.rows?.length || 0} 名考生正式录取。`
}));
const cutoffs = (data.cutoffs || []).map(item => ({
...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线',
title: `${item.examName}录取分数线`, summary: `公布 ${item.rows?.length || 0} 条学校及类别录取分数线。`
}));
const reports = (data.reports || []).map(item => ({
...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示',
subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况'
}));
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports]
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
+5 -60
View File
@@ -1,64 +1,9 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import { router } from './router';
import './styles/home.css'; import './styles/home.css';
import './styles/app.css';
const root = document.querySelector('#app'); const app = createApp(App);
let vueApp = null; app.use(router);
let legacyLoading = null; app.mount('#app');
window.__EIS_VUE_SHELL__ = true;
function currentRoute() {
return location.hash.slice(1) || 'home';
}
function isVueRoute(route) {
return route === 'home';
}
function showLegacyBoot() {
root.innerHTML = `
<div class="hz-loading" role="status" aria-live="polite">
<span class="hz-loading__seal" aria-hidden="true">衡</span>
<strong>正在进入业务中心</strong>
<small>请稍候</small>
</div>`;
}
function loadLegacy() {
if (legacyLoading) return legacyLoading;
if (vueApp) {
vueApp.unmount();
vueApp = null;
}
showLegacyBoot();
legacyLoading = import(/* @vite-ignore */ '/js/app.js?v=20260723-vue').catch(error => {
console.error('Legacy application failed to load', error);
root.innerHTML = '<main class="hz-failure"><span>服务提示</span><h1>业务中心暂时无法加载</h1><p>请刷新页面后重试。</p><button type="button" onclick="location.reload()">刷新页面</button></main>';
throw error;
});
return legacyLoading;
}
function mountVue() {
if (vueApp) return;
root.innerHTML = '';
vueApp = createApp(App);
vueApp.mount(root);
}
function renderEntry() {
if (isVueRoute(currentRoute())) mountVue();
else loadLegacy();
}
window.addEventListener('hashchange', () => {
const route = currentRoute();
if (isVueRoute(route) && legacyLoading) {
location.reload();
return;
}
if (!isVueRoute(route) && !legacyLoading) loadLegacy();
});
renderEntry();
+99
View File
@@ -0,0 +1,99 @@
import { createRouter, createWebHistory } from 'vue-router';
import { sessionStore } from '@/stores/session';
import HomeView from '@/views/public/HomeView.vue';
import AnnouncementListView from '@/views/public/AnnouncementListView.vue';
import AnnouncementDetailView from '@/views/public/AnnouncementDetailView.vue';
import VerificationView from '@/views/public/VerificationView.vue';
import AuthView from '@/views/public/AuthView.vue';
import CandidatePage from '@/views/candidate/CandidatePage.vue';
import AdminPage from '@/views/admin/AdminPage.vue';
import AdmissionPage from '@/views/admission/AdmissionPage.vue';
import NotFoundView from '@/views/public/NotFoundView.vue';
const candidatePages = [
['onboarding', '首次登录'], ['dashboard', '总览'], ['profile', '个人资料'], ['exams', '考试报名'],
['registrations', '我的报名'], ['admit', '准考证'], ['results', '成绩查询'],
['admissions', '志愿与录取'], ['notices', '通知公告'], ['security', '账户安全']
];
const adminPages = [
['dashboard', '考务工作台'], ['schools', '学校管理'], ['organization', '本校组织'],
['admins', '管理员'], ['account-batches', '批量建号'], ['candidates', '考生信息'],
['indicator-qualifications', '指标资格确认'], ['registrations', '报名审核'], ['payments', '缴费名单'],
['admit', '准考证编排'], ['exams', '考试与科目'], ['results', '成绩管理'],
['admission-settings', '录取设置'], ['admission-accounts', '招生账户'],
['admission-plans', '招生计划'], ['admission-reporting', '报到与补录'],
['admission-supervision', '投档监督'], ['notices', '通知发布'], ['centers', '考场信息'],
['flows', '流程中心'], ['flow-design', '流程设计'], ['number-rules', '报名号规则'], ['security', '账户安全']
];
const admissionPages = [
['dashboard', '招生工作台'], ['plans', '招生计划'], ['placements', '投档审核'],
['reporting', '考生报到'], ['notice-template', '通知书模板']
];
const routes = [
{ path: '/', name: 'home', component: HomeView, meta: { public: true, title: '首页' } },
{ path: '/announcements', name: 'announcements', component: AnnouncementListView, meta: { public: true, title: '通知公告' } },
{ path: '/announcements/:id', name: 'announcement-detail', component: AnnouncementDetailView, meta: { public: true, title: '公告详情' } },
{ path: '/verify/:code?', name: 'verification', component: VerificationView, meta: { public: true, title: '文书防伪查询' } },
{ path: '/auth/login', name: 'login', component: AuthView, props: { mode: 'login' }, meta: { public: true, guest: true, title: '登录' } },
{ path: '/auth/register', name: 'register', component: AuthView, props: { mode: 'register' }, meta: { public: true, guest: true, title: '考生注册' } },
{ path: '/candidate', redirect: '/candidate/dashboard' },
...candidatePages.map(([page, title]) => ({
path: `/candidate/${page}`,
name: `candidate-${page}`,
component: CandidatePage,
props: { page },
meta: { roles: ['candidate'], title }
})),
{ path: '/admin', redirect: '/admin/dashboard' },
...adminPages.map(([page, title]) => ({
path: `/admin/${page}`,
name: `admin-${page}`,
component: AdminPage,
props: { page },
meta: { roles: ['admin'], title }
})),
{ path: '/admission', redirect: '/admission/dashboard' },
...admissionPages.map(([page, title]) => ({
path: `/admission/${page}`,
name: `admission-${page}`,
component: AdmissionPage,
props: { page },
meta: { roles: ['admission_school'], title }
})),
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { public: true, title: '页面不存在' } }
];
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.hash) return { el: to.hash, behavior: 'smooth' };
return { top: 0 };
}
});
router.beforeEach(async to => {
try {
await sessionStore.bootstrap();
} catch {
if (!to.meta.public) return { name: 'home' };
}
const user = sessionStore.state.user;
if (to.meta.guest && user) return sessionStore.homeFor(user);
if (to.meta.roles?.length) {
if (!user) return { name: 'login', query: { redirect: to.fullPath } };
if (!to.meta.roles.includes(user.role)) return sessionStore.homeFor(user);
if (user.role === 'candidate' && to.path !== '/candidate/onboarding') {
if (user.mustChangePassword || !sessionStore.state.profile?.profileCompleted) return '/candidate/onboarding';
}
}
document.title = `${to.meta.title || '服务'} · 衡准考试信息管理系统`;
return true;
});
export { candidatePages, adminPages, admissionPages };
+101
View File
@@ -0,0 +1,101 @@
import { computed, reactive } from 'vue';
import { api } from '@/lib/api';
const state = reactive({
initialized: false,
loading: false,
error: '',
publicData: { organization: {}, notices: [], exams: [], stats: {} },
user: null,
profile: null,
permissions: [],
scopeLabel: ''
});
let bootstrapPromise;
async function loadPublic() {
state.publicData = await api('/api/public/home');
return state.publicData;
}
async function refreshSession() {
try {
const session = await api('/api/auth/me');
state.user = session.user || null;
state.profile = session.profile || null;
state.permissions = session.permissions || [];
state.scopeLabel = session.scopeLabel || '';
return session;
} catch (error) {
if (error.status !== 401) throw error;
clearSession();
return { user: null, profile: null, permissions: [] };
}
}
async function bootstrap({ refresh = false } = {}) {
if (state.initialized && !refresh) return state;
if (bootstrapPromise && !refresh) return bootstrapPromise;
state.loading = true;
state.error = '';
bootstrapPromise = Promise.all([loadPublic(), refreshSession()])
.then(() => {
state.initialized = true;
return state;
})
.catch(error => {
state.error = error.message || '系统基础信息加载失败';
throw error;
})
.finally(() => {
state.loading = false;
bootstrapPromise = null;
});
return bootstrapPromise;
}
function setSession(session) {
state.user = session.user || null;
state.profile = session.profile || null;
state.permissions = session.permissions || [];
state.scopeLabel = session.scopeLabel || '';
}
function clearSession() {
state.user = null;
state.profile = null;
state.permissions = [];
state.scopeLabel = '';
}
async function logout() {
await api('/api/auth/logout', { method: 'POST' });
clearSession();
await loadPublic();
}
function homeFor(user = state.user) {
if (!user) return '/auth/login';
if (user.role === 'candidate') {
if (user.mustChangePassword || !state.profile?.profileCompleted) return '/candidate/onboarding';
return '/candidate/dashboard';
}
if (user.role === 'admission_school') return '/admission/dashboard';
return '/admin/dashboard';
}
export const sessionStore = {
state,
user: computed(() => state.user),
profile: computed(() => state.profile),
publicData: computed(() => state.publicData),
isAuthenticated: computed(() => Boolean(state.user)),
bootstrap,
loadPublic,
refreshSession,
setSession,
clearSession,
logout,
homeFor
};
+32
View File
@@ -0,0 +1,32 @@
import { reactive } from 'vue';
const state = reactive({
toast: null,
modal: null,
sidebarOpen: false
});
let toastTimer;
function notify(title, message = '', tone = 'success') {
state.toast = { title, message, tone };
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
state.toast = null;
}, 3200);
}
function openModal(component, props = {}) {
state.modal = { component, props };
}
function closeModal() {
state.modal = null;
}
export const uiStore = {
state,
notify,
openModal,
closeModal
};
+671
View File
@@ -0,0 +1,671 @@
:root {
--app-navy: #0d2d54;
--app-blue: #17558f;
--app-red: #9f3038;
--app-ink: #172538;
--app-muted: #647386;
--app-line: #d7e0e9;
--app-bg: #f3f6f9;
--app-white: #fff;
--app-shadow: 0 10px 28px rgba(13, 45, 84, .07);
--app-title: "Noto Serif SC", "Source Han Serif SC", "Songti SC", STSong, SimSun, serif;
--app-body: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--app-bg); color: var(--app-ink); font-family: var(--app-body); font-size: 14px; line-height: 1.55; }
button, input, select, textarea { box-sizing: border-box; font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid #e1ab24; outline-offset: 2px; }
.app-container { width: min(1180px, calc(100% - 48px)); margin-inline: auto; }
.app-brand { display: inline-flex; align-items: center; gap: 12px; color: var(--app-navy); text-decoration: none; }
.app-brand > span { width: 42px; height: 42px; display: grid; place-items: center; background: var(--app-red); color: #fff; font-family: var(--app-title); font-size: 22px; font-weight: 800; box-shadow: inset 0 0 0 3px rgba(255,255,255,.25); }
.app-brand > div { display: flex; flex-direction: column; line-height: 1.1; }
.app-brand strong { font-family: var(--app-title); font-size: 18px; letter-spacing: .08em; }
.app-brand small { margin-top: 6px; font-size: 8px; font-weight: 700; letter-spacing: .1em; }
.app-brand--light { color: #fff; }
.app-button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0 18px; border: 1px solid var(--app-line); border-radius: 2px; background: #fff; color: var(--app-navy); font-weight: 700; text-decoration: none; cursor: pointer; }
.app-button--primary { border-color: var(--app-navy); background: var(--app-navy); color: #fff; }
.app-button--primary:hover { background: #071c35; }
.app-button--large { min-height: 49px; }
.app-button:disabled { opacity: .55; cursor: not-allowed; }
.app-link-button { border: 0; background: transparent; color: var(--app-muted); cursor: pointer; }
.public-frame { min-height: 100vh; display: flex; flex-direction: column; background: #fff; }
.public-frame__utility { min-height: 36px; display: flex; align-items: center; background: #071c35; color: #d8e5f1; font-size: 11px; }
.public-frame__utility .app-container { display: flex; justify-content: space-between; }
.public-frame__header { position: sticky; z-index: 30; top: 0; border-bottom: 1px solid var(--app-line); background: rgba(255,255,255,.97); box-shadow: 0 7px 22px rgba(12,42,73,.06); backdrop-filter: blur(14px); }
.public-frame__header > .app-container { min-height: 76px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 35px; }
.public-frame__header nav { display: flex; justify-content: center; gap: 7px; }
.public-frame__header nav a { padding: 12px 15px; color: #33465c; font-size: 13px; font-weight: 700; text-decoration: none; }
.public-frame__header nav a.router-link-active { color: var(--app-navy); box-shadow: inset 0 -3px var(--app-red); }
.public-frame__actions { display: flex; align-items: center; gap: 8px; }
.public-frame__menu { width: 42px; height: 42px; display: none; border: 0; background: transparent; color: var(--app-navy); font-size: 21px; }
.public-frame__main { flex: 1; }
.public-frame__footer { margin-top: 80px; border-top: 1px solid var(--app-line); background: #e9eef3; }
.public-frame__footer .app-container { min-height: 110px; display: flex; align-items: center; justify-content: space-between; gap: 30px; }
.public-frame__footer div > div { display: flex; flex-direction: column; }
.public-frame__footer strong { color: var(--app-navy); font-family: var(--app-title); }
.public-frame__footer span { color: var(--app-muted); font-size: 11px; }
.public-page-head { padding: 62px 0; background: var(--app-navy); color: #fff; }
.public-page-head p, .verification-page__intro p, .auth-card > p, .business-form > p, .record-panel > header span { margin: 0 0 9px; color: #79add8; font-size: 10px; font-weight: 800; letter-spacing: .18em; }
.public-page-head h1 { margin: 0; font-family: var(--app-title); font-size: 38px; }
.public-page-head span { display: block; margin-top: 12px; color: #b9cbdb; font-size: 13px; }
.public-directory { display: grid; grid-template-columns: 230px 1fr; gap: 44px; padding-top: 54px; }
.public-directory > .page-state { grid-column: 1 / -1; }
.public-directory > :not(.page-state) { display: contents; }
.public-directory__filters { align-self: start; display: flex; flex-direction: column; border-top: 3px solid var(--app-navy); background: var(--app-bg); }
.public-directory__filters > strong { padding: 20px; font-family: var(--app-title); }
.public-directory__filters button { display: flex; justify-content: space-between; padding: 12px 20px; border: 0; border-top: 1px solid var(--app-line); background: transparent; color: #45586d; text-align: left; cursor: pointer; }
.public-directory__filters button.active { background: var(--app-navy); color: #fff; }
.public-directory__filters button span { font-size: 10px; }
.directory-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.directory-toolbar label { flex: 1; display: flex; flex-direction: column; gap: 7px; color: var(--app-muted); font-size: 11px; }
.directory-toolbar input, .record-search input { min-height: 45px; padding: 0 14px; border: 1px solid var(--app-line); background: #fff; }
.directory-list { border-top: 2px solid var(--app-navy); }
.directory-list > button { width: 100%; min-height: 108px; display: grid; grid-template-columns: 70px 1fr auto; align-items: center; gap: 22px; padding: 16px; border: 0; border-bottom: 1px solid var(--app-line); background: #fff; color: var(--app-ink); text-align: left; cursor: pointer; }
.directory-list > button:hover { background: #f7f9fb; }
.directory-list time { display: flex; align-items: center; flex-direction: column; border-right: 1px solid var(--app-line); }
.directory-list time strong { font-family: var(--app-title); font-size: 26px; }
.directory-list time span { color: var(--app-muted); font-size: 9px; }
.directory-list > button > span { min-width: 0; display: flex; flex-direction: column; }
.directory-list em { color: var(--app-red); font-size: 10px; font-style: normal; }
.directory-list > button > span strong { margin: 4px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.directory-list small { color: var(--app-muted); font-size: 11px; }
.directory-list i { color: var(--app-blue); font-style: normal; }
.app-pagination { display: flex; align-items: center; justify-content: center; gap: 18px; padding-top: 24px; }
.app-pagination button { padding: 8px 14px; border: 1px solid var(--app-line); background: #fff; cursor: pointer; }
.app-pagination button:disabled { opacity: .45; }
.app-pagination span { color: var(--app-muted); font-size: 11px; }
.document-page { padding-top: 38px; }
.document-page__back { margin-bottom: 18px; padding: 8px 0; border: 0; background: transparent; color: var(--app-blue); cursor: pointer; }
.public-document { overflow: hidden; border: 1px solid var(--app-line); background: #fff; box-shadow: 0 20px 45px rgba(13,45,84,.07); }
.public-document > header { padding: 48px max(32px, 8vw); border-bottom: 1px solid var(--app-line); background: #f7f9fb; text-align: center; }
.public-document > header span { color: var(--app-red); font-size: 11px; font-weight: 800; letter-spacing: .08em; }
.public-document > header h1 { margin: 16px 0 10px; font-family: var(--app-title); font-size: 32px; line-height: 1.5; }
.public-document > header p { color: var(--app-muted); font-size: 11px; }
.public-document > section { padding: 42px max(30px, 7vw); }
.document-richtext { font-size: 15px; line-height: 2; }
.document-richtext img { max-width: 100%; }
.document-table-wrap { overflow-x: auto; }
.document-table-wrap > p { color: var(--app-muted); }
.document-table-wrap table, .record-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; }
.document-table-wrap th, .document-table-wrap td, .record-table-wrap th, .record-table-wrap td { padding: 12px 14px; border-bottom: 1px solid var(--app-line); text-align: left; vertical-align: top; }
.document-table-wrap th, .record-table-wrap th { background: #edf2f6; color: #42566b; font-size: 10px; white-space: nowrap; }
.record-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.record-metrics article { min-height: 105px; display: flex; justify-content: center; flex-direction: column; padding: 18px; border: 1px solid var(--app-line); background: #fff; }
.record-metrics article span { color: var(--app-muted); font-size: 11px; }
.record-metrics article strong { margin-top: 5px; color: var(--app-navy); font-family: var(--app-title); font-size: 25px; }
.verification-page { padding-top: 70px; }
.verification-page__intro { max-width: 700px; }
.verification-page__intro h1 { margin: 0; color: var(--app-navy); font-family: var(--app-title); font-size: 42px; }
.verification-page__intro > span { color: var(--app-muted); }
.verification-form { display: grid; grid-template-columns: 1fr auto; gap: 12px; margin: 34px 0; padding: 22px; border: 1px solid var(--app-line); background: #fff; }
.verification-form label { display: flex; flex-direction: column; gap: 7px; color: var(--app-muted); font-size: 11px; }
.verification-form input { min-height: 46px; padding: 0 15px; border: 1px solid var(--app-line); font-family: ui-monospace, Consolas, monospace; }
.verification-form button { align-self: end; min-height: 46px; padding: 0 25px; border: 0; background: var(--app-navy); color: #fff; font-weight: 700; }
.verification-result { display: grid; grid-template-columns: auto 1fr; gap: 22px; padding: 30px; border: 1px solid var(--app-line); background: #fff; }
.verification-result > span { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 50%; background: #e2f3e9; color: #197346; font-size: 24px; }
.verification-result.is-invalid > span { background: #f8e8e8; color: var(--app-red); }
.verification-result h2 { margin: 3px 0; font-family: var(--app-title); }
.verification-result p { margin: 0; color: var(--app-muted); }
.verification-result dl { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 12px 0 0; background: var(--app-line); }
.verification-result dl div { padding: 15px; background: #f8fafb; }
.verification-result dt { color: var(--app-muted); font-size: 10px; }
.verification-result dd { margin: 5px 0 0; font-weight: 700; }
.verification-safety { margin-top: 18px; padding: 18px 20px; border-left: 3px solid var(--app-blue); background: #eaf1f7; }
.verification-safety p { margin: 4px 0 0; color: var(--app-muted); font-size: 11px; }
.auth-view { min-height: 100vh; display: grid; grid-template-columns: minmax(330px, .8fr) minmax(520px, 1.2fr); background: #fff; }
.auth-view__identity { min-height: 100vh; display: flex; justify-content: space-between; flex-direction: column; padding: 48px 9vw 48px 5vw; background: var(--app-navy); color: #fff; }
.auth-view__identity > div p { color: #7db0da; font-size: 10px; font-weight: 800; letter-spacing: .18em; }
.auth-view__identity h1 { max-width: 540px; margin: 14px 0; font-family: var(--app-title); font-size: clamp(36px, 4vw, 58px); line-height: 1.35; }
.auth-view__identity > div > span, .auth-view__identity > small { color: #aebfd0; line-height: 1.9; }
.auth-view__panel { display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 50px 6vw; }
.auth-view__back { align-self: flex-start; color: var(--app-blue); font-size: 12px; text-decoration: none; }
.auth-card { width: min(520px, 100%); display: flex; flex-direction: column; margin: auto; }
.auth-card h2, .business-form h2 { margin: 4px 0 8px; color: var(--app-navy); font-family: var(--app-title); font-size: 30px; }
.auth-card > span, .business-form > span { margin-bottom: 25px; color: var(--app-muted); font-size: 12px; line-height: 1.8; }
.auth-card label, .business-form label { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; color: #506175; font-size: 11px; }
.auth-card input, .auth-card select, .business-form input, .business-form select, .business-form textarea, .preference-row select { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid #cbd6e1; background: #fff; color: var(--app-ink); }
.auth-card textarea, .business-form textarea { resize: vertical; }
.auth-card__switch { color: var(--app-muted); font-size: 11px; text-align: center; }
.auth-card__switch a { color: var(--app-blue); }
.form-error { margin-bottom: 16px; padding: 12px 14px; border-left: 3px solid var(--app-red); background: #f9ebeb; color: #8c2c33; font-size: 12px; }
.issued-card > strong { margin: 25px 0; padding: 20px; border: 1px dashed var(--app-red); color: var(--app-navy); font-family: ui-monospace, Consolas, monospace; font-size: 25px; text-align: center; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 15px; }
.form-grid .wide { grid-column: 1 / -1; }
.portal-shell { min-height: 100vh; background: var(--app-bg); }
.portal-shell__sidebar { position: fixed; z-index: 50; inset: 0 auto 0 0; width: 252px; display: flex; flex-direction: column; overflow-y: auto; background: #0a2748; color: #fff; }
.portal-shell__brand { display: flex; align-items: center; gap: 10px; min-height: 74px; padding: 0 20px; color: #fff; text-decoration: none; }
.portal-shell__brand > span { width: 38px; height: 38px; display: grid; place-items: center; background: var(--app-red); font-family: var(--app-title); font-size: 20px; }
.portal-shell__brand div { display: flex; flex-direction: column; }
.portal-shell__brand strong { font-family: var(--app-title); font-size: 15px; }
.portal-shell__brand small { color: #8eabc6; font-size: 7px; letter-spacing: .12em; }
.portal-shell__close { display: none; }
.portal-shell__role { margin: 0; padding: 12px 20px; border-block: 1px solid rgba(255,255,255,.1); color: #a9bfd4; font-size: 11px; }
.portal-shell__sidebar nav { padding: 13px 10px 25px; }
.portal-shell__sidebar nav section > strong { display: block; padding: 15px 10px 5px; color: #7895b0; font-size: 9px; letter-spacing: .14em; }
.portal-shell__sidebar nav a { min-height: 39px; display: flex; align-items: center; gap: 11px; padding: 0 10px; border-radius: 2px; color: #cad8e5; font-size: 12px; text-decoration: none; }
.portal-shell__sidebar nav a > span { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid rgba(255,255,255,.13); color: #9fb5ca; font-family: var(--app-title); font-size: 10px; }
.portal-shell__sidebar nav a:hover, .portal-shell__sidebar nav a.router-link-active { background: #17456f; color: #fff; }
.portal-shell__scope { margin: auto 14px 16px; padding: 14px; background: rgba(255,255,255,.07); }
.portal-shell__scope span, .portal-shell__scope small { display: block; color: #8fa9c1; font-size: 9px; }
.portal-shell__scope strong { display: block; margin: 5px 0; font-size: 11px; }
.portal-shell__main { min-height: 100vh; margin-left: 252px; }
.portal-shell__topbar { position: sticky; z-index: 25; top: 0; min-height: 64px; display: grid; grid-template-columns: 1fr auto; align-items: center; padding: 0 30px; border-bottom: 1px solid var(--app-line); background: rgba(255,255,255,.97); backdrop-filter: blur(12px); }
.portal-shell__topbar > button { display: none; }
.portal-shell__topbar > div:first-of-type { display: flex; align-items: center; gap: 9px; color: var(--app-muted); font-size: 11px; }
.portal-shell__topbar b { color: #b9c4cf; }
.portal-shell__topbar strong { color: var(--app-navy); }
.portal-shell__user { display: flex; align-items: center; gap: 9px; }
.portal-shell__user > i { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 50%; background: #dce8f2; color: var(--app-navy); font-family: var(--app-title); font-style: normal; }
.portal-shell__user > span { display: flex; flex-direction: column; }
.portal-shell__user small { max-width: 170px; overflow: hidden; color: var(--app-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.portal-shell__user > button { border: 0; background: transparent; color: var(--app-muted); font-size: 10px; cursor: pointer; }
.portal-shell__content { padding: 30px; }
.portal-page-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 25px; }
.portal-page-heading p { margin: 0 0 4px; color: var(--app-blue); font-size: 9px; font-weight: 800; letter-spacing: .18em; }
.portal-page-heading h1 { margin: 0; color: var(--app-navy); font-family: var(--app-title); font-size: 29px; }
.portal-page-heading span { display: block; margin-top: 5px; color: var(--app-muted); font-size: 11px; }
.record-explorer { display: flex; flex-direction: column; gap: 18px; }
.record-search { display: flex; flex-direction: column; gap: 6px; color: var(--app-muted); font-size: 10px; }
.record-panel { overflow: hidden; border: 1px solid var(--app-line); background: #fff; }
.record-panel > header { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 20px; border-bottom: 1px solid var(--app-line); }
.record-panel > header h2 { margin: 0; color: var(--app-navy); font-family: var(--app-title); font-size: 17px; }
.record-panel > header p { margin: 2px 0 0; color: var(--app-muted); font-size: 10px; }
.record-table-wrap { overflow-x: auto; }
.record-table-wrap td { max-width: 330px; word-break: break-word; }
.status-badge { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 20px; background: #e9eef3; color: #53657a; font-size: 9px; font-weight: 750; white-space: nowrap; }
.status-badge.is-approved, .status-badge.is-active, .status-badge.is-open, .status-badge.is-paid, .status-badge.is-final, .status-badge.is-reported { background: #e1f3e8; color: #197044; }
.status-badge.is-pending, .status-badge.is-upcoming, .status-badge.is-school-review, .status-badge.is-withdrawal-pending { background: #fff1d7; color: #8a5c11; }
.status-badge.is-rejected, .status-badge.is-disabled, .status-badge.is-not-reported { background: #f8e5e7; color: #922f38; }
.status-badge.is-published { background: #dfeaf6; color: #195c93; }
.page-state { min-height: 270px; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 28px; border: 1px solid var(--app-line); background: #fff; text-align: center; }
.page-state p { max-width: 560px; margin: 6px 0; color: var(--app-muted); font-size: 11px; }
.page-state button { margin-top: 12px; padding: 9px 16px; border: 0; background: var(--app-navy); color: #fff; cursor: pointer; }
.page-state--loading i { width: 24px; height: 24px; margin-bottom: 12px; border: 3px solid #d8e2ec; border-top-color: var(--app-blue); border-radius: 50%; animation: app-spin .8s linear infinite; }
.page-state--error > span { width: 38px; height: 38px; display: grid; place-items: center; margin-bottom: 10px; border-radius: 50%; background: #f8e4e5; color: var(--app-red); font-weight: 800; }
@keyframes app-spin { to { transform: rotate(360deg); } }
.candidate-welcome-vue { min-height: 180px; display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 32px; background: var(--app-navy); color: #fff; }
.candidate-welcome-vue > div > span { color: #82b1d7; font-size: 11px; }
.candidate-welcome-vue h2 { margin: 7px 0; font-family: var(--app-title); font-size: 28px; }
.candidate-welcome-vue p { margin: 0; color: #b6c8d8; font-size: 12px; }
.candidate-welcome-vue > strong { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid rgba(255,255,255,.5); color: rgba(255,255,255,.75); font-family: var(--app-title); font-size: 22px; line-height: 1.1; text-align: center; }
.candidate-dashboard-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-top: 15px; }
.dashboard-row, .notice-list-vue > button { width: 100%; min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 20px; border: 0; border-bottom: 1px solid var(--app-line); background: #fff; color: var(--app-ink); text-align: left; cursor: pointer; }
.dashboard-row > span, .notice-list-vue > button > span { min-width: 0; display: flex; flex-direction: column; }
.dashboard-row small, .notice-list-vue small { color: var(--app-muted); font-size: 9px; }
.business-form { padding: 26px; border: 1px solid var(--app-line); background: #fff; }
.business-form > h2 { margin-top: 0; }
.profile-fields > h2 { margin: 28px 0 15px; padding-bottom: 8px; border-bottom: 1px solid var(--app-line); color: var(--app-navy); font-family: var(--app-title); font-size: 18px; }
.profile-fields > h2:first-child { margin-top: 0; }
.form-callout { margin: 15px 0; padding: 15px; border-left: 3px solid var(--app-blue); background: #eaf1f7; }
.form-callout p { margin: 4px 0 0; color: var(--app-muted); font-size: 11px; }
.business-card-list { display: flex; flex-direction: column; gap: 16px; }
.exam-apply-card, .registration-vue-card, .admit-card-vue { padding: 25px; border: 1px solid var(--app-line); background: #fff; }
.exam-apply-card > header, .registration-vue-card > header, .admit-card-vue > header { display: flex; align-items: center; justify-content: space-between; gap: 15px; }
.exam-apply-card > header > span, .registration-vue-card header span, .admit-card-vue header span { color: var(--app-blue); font-family: ui-monospace, Consolas, monospace; font-size: 10px; }
.exam-apply-card h2, .registration-vue-card h2, .admit-card-vue h2 { margin: 18px 0 8px; color: var(--app-navy); font-family: var(--app-title); }
.exam-apply-card > p, .registration-vue-card > p { color: var(--app-muted); font-size: 11px; }
.exam-apply-card dl, .registration-vue-card dl, .admit-card-vue dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 1px; margin: 20px 0; background: var(--app-line); }
.exam-apply-card dl div, .registration-vue-card dl div, .admit-card-vue dl div { padding: 12px; background: #f8fafb; }
.exam-apply-card dt, .registration-vue-card dt, .admit-card-vue dt { color: var(--app-muted); font-size: 9px; }
.exam-apply-card dd, .registration-vue-card dd, .admit-card-vue dd { margin: 4px 0 0; font-size: 11px; font-weight: 700; }
.subject-choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px; margin: 20px 0; }
.subject-choice-grid label { margin: 0; cursor: pointer; }
.subject-choice-grid input { position: absolute; opacity: 0; }
.subject-choice-grid label > span { min-height: 75px; display: flex; flex-direction: column; padding: 13px; border: 1px solid var(--app-line); }
.subject-choice-grid input:checked + span { border-color: var(--app-blue); background: #edf5fb; box-shadow: inset 3px 0 var(--app-blue); }
.subject-choice-grid small { color: var(--app-muted); font-size: 9px; }
.subject-choice-grid em { margin-top: auto; color: var(--app-red); font-size: 10px; font-style: normal; }
.exam-apply-card > footer { display: flex; align-items: center; justify-content: space-between; padding: 13px; background: #edf5f0; }
.chip-list { display: flex; flex-wrap: wrap; gap: 7px; }
.chip-list > span { display: flex; flex-direction: column; padding: 7px 10px; border: 1px solid var(--app-line); background: #f8fafb; font-size: 10px; }
.chip-list small { color: var(--app-muted); font-size: 8px; }
.admit-card-vue > div { margin: 20px 0; padding: 20px; background: var(--app-navy); color: #fff; }
.admit-card-vue > div small { display: block; color: #a9bfd3; }
.admit-card-vue > div strong { font-family: ui-monospace, Consolas, monospace; font-size: 25px; }
.result-group > header { padding: 16px 20px; }
.result-group > header h2 { margin: 3px 0 0; }
.result-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; padding: 16px; }
.result-card-grid article { display: flex; flex-direction: column; padding: 18px; border: 1px solid var(--app-line); }
.result-card-grid article > span { color: var(--app-blue); font-size: 10px; }
.result-card-grid article > strong { margin: 5px 0; color: var(--app-navy); font-family: var(--app-title); font-size: 32px; }
.result-card-grid article > strong small { color: var(--app-muted); font-family: var(--app-body); font-size: 11px; }
.result-card-grid article > em { margin-bottom: 12px; color: var(--app-muted); font-size: 9px; font-style: normal; }
.result-card-grid form { display: flex; flex-direction: column; gap: 7px; margin-top: auto; }
.result-card-grid textarea { padding: 9px; border: 1px solid var(--app-line); resize: vertical; }
.result-card-grid form button { align-self: flex-end; padding: 7px 11px; border: 0; background: var(--app-navy); color: #fff; font-size: 9px; }
.admission-candidate-vue > header { padding: 18px 20px; }
.admission-candidate-vue > .record-metrics, .admission-candidate-vue > .form-callout, .admission-candidate-vue > p { margin: 16px; }
.preference-editor { padding: 16px; border-top: 1px solid var(--app-line); }
.preference-row { display: grid; grid-template-columns: 45px 1fr 1fr; gap: 10px; margin-bottom: 9px; }
.preference-row > b { display: grid; place-items: center; background: #e8eef4; color: var(--app-navy); font-size: 10px; }
.notice-list-vue > button time { color: var(--app-muted); font-size: 9px; }
.notice-list-vue em { color: var(--app-red); font-size: 9px; font-style: normal; }
.security-stack { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.security-stack > .form-error, .security-stack > .recovery-code-panel { grid-column: 1 / -1; }
.security-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
.security-card > header h2, .security-card > header p { margin: 0; }
.security-card > span { display: block; margin-bottom: 18px; color: var(--app-muted); line-height: 1.7; }
.inline-security-form, .security-protected-actions { display: grid; gap: 12px; }
.security-protected-actions > div { display: flex; flex-wrap: wrap; gap: 8px; }
.app-button--danger { border-color: #b22e35 !important; color: #a5222a !important; background: #fff !important; }
.totp-setup-grid { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: center; margin: 8px 0 20px; padding: 18px; border: 1px solid var(--app-line); background: #f7f9fb; }
.totp-setup-grid img { display: block; width: 100%; height: auto; background: #fff; }
.totp-setup-grid > div { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.totp-setup-grid code { overflow-wrap: anywhere; color: var(--app-navy); font-size: 14px; font-weight: 700; line-height: 1.7; }
.totp-setup-grid small { color: var(--app-muted); }
.recovery-code-panel { padding: 24px; border-left: 5px solid #d28b1d; background: #fff9eb; box-shadow: var(--app-shadow); }
.recovery-code-panel h2, .recovery-code-panel p { margin: 0; }
.recovery-code-panel span { color: #766540; }
.recovery-code-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin: 18px 0; }
.recovery-code-grid code { padding: 10px; border: 1px dashed #c69b43; background: #fff; text-align: center; font-size: 13px; font-weight: 800; }
.admission-command-banner { min-height: 190px; display: flex; align-items: flex-end; padding: 30px; color: #fff; background: linear-gradient(112deg, rgba(7,35,62,.97), rgba(14,68,104,.86)), repeating-linear-gradient(135deg, transparent 0 18px, rgba(255,255,255,.04) 18px 19px); box-shadow: var(--app-shadow); }
.admission-command-banner span { color: #80b5da; font-size: 9px; letter-spacing: .17em; }
.admission-command-banner h2 { margin: 6px 0; font-family: var(--app-title); font-size: 28px; }
.admission-command-banner p { max-width: 720px; margin: 0; color: #c6d5e2; }
.admission-progress-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 14px; margin: 16px 0; }
.admission-progress-grid article { padding: 18px; border: 1px solid var(--app-line); background: #fff; }
.admission-progress-grid header { display: flex; justify-content: space-between; gap: 12px; }
.admission-progress-grid header strong { color: var(--app-red); font-size: 18px; }
.admission-progress-grid article > div { height: 5px; margin: 12px 0; overflow: hidden; background: #e5ebef; }
.admission-progress-grid article > div i { display: block; height: 100%; background: var(--app-red); }
.admission-progress-grid p, .admission-progress-grid small { margin: 0; color: var(--app-muted); }
.admission-dashboard-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
.admission-dashboard-grid .dashboard-row > b { display: grid; width: 34px; height: 34px; place-items: center; background: #eaf0f5; color: var(--app-navy); }
.admission-plan-form { margin-bottom: 16px; }
.admission-plan-form > header h2, .admission-plan-form > header p { margin: 0; }
.plan-category-list { display: grid; gap: 12px; }
.plan-category-card { border: 1px solid var(--app-line); background: #fafbfc; }
.plan-category-card > header, .plan-category-card > section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 15px; border-bottom: 1px solid var(--app-line); }
.plan-category-card > header button, .plan-category-card > section button, .allocation-row button { border: 0; background: transparent; color: var(--app-red); }
.plan-category-card > .form-grid { padding: 14px; }
.plan-category-card > section { margin: 0 14px 14px; border: 1px solid var(--app-line); background: #fff; }
.plan-category-card > section small { display: block; color: var(--app-muted); font-weight: 400; }
.allocation-row { display: grid; grid-template-columns: 1fr 140px auto; gap: 8px; padding: 9px 12px; border-top: 1px solid #eef1f3; }
.table-stack { display: flex; flex-direction: column; margin-bottom: 4px; }
.ledger-panel > header, .admission-plan-history > header { padding: 18px 20px; }
.ledger-toolbar { display: grid; grid-template-columns: minmax(240px, 1fr) 220px 170px; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--app-line); background: #f6f8fa; }
.ledger-bulk { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; padding: 11px 16px; border-top: 1px solid var(--app-line); border-bottom: 1px solid var(--app-line); }
.ledger-bulk > strong { margin-right: auto; }
.row-review-form { min-width: 190px; display: grid; gap: 5px; }
.row-review-form button { padding: 7px; border: 0; background: var(--app-navy); color: #fff; }
.admission-export-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 18px 20px; background: #fff; box-shadow: var(--app-shadow); }
.admission-export-bar > div { display: flex; flex: 1; flex-direction: column; }
.admission-export-bar > div > span { color: var(--app-red); font-size: 9px; }
.admission-export-bar small { color: var(--app-muted); }
.app-button.disabled { pointer-events: none; opacity: .45; }
.reporting-workbench { margin-bottom: 18px; border: 1px solid var(--app-line); background: #fff; box-shadow: var(--app-shadow); }
.reporting-workbench > header { display: flex; justify-content: space-between; gap: 18px; padding: 22px; background: var(--app-navy); color: #fff; }
.reporting-workbench > header h2 { margin: 4px 0; }
.reporting-workbench > header p { margin: 0; color: #b9ccdb; }
.reporting-workbench > header > strong { font-family: var(--app-title); font-size: 30px; text-align: right; }
.reporting-workbench > header > strong small { display: block; color: #9cb5c9; font-family: var(--app-body); font-size: 9px; }
.reporting-stat-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 11px 18px; background: #e9eef3; }
.reporting-stat-strip .status-badge { margin-left: auto; }
.reporting-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
.reporting-tools > div, .reporting-tools > form { display: flex; flex-direction: column; gap: 8px; padding: 15px; border: 1px solid var(--app-line); }
.reporting-tools small { color: var(--app-muted); }
.reporting-tools > div > span { display: flex; gap: 8px; }
.reporting-workbench form > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 16px; }
.scan-preview { margin: 0 16px 16px; padding: 16px; border: 2px solid #218252; background: #f2faf6; }
.scan-preview > header { display: flex; justify-content: space-between; }
.scan-preview dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.scan-preview dl > div { padding: 9px; background: #fff; }
.scan-preview dt { color: var(--app-muted); font-size: 9px; }
.scan-preview dd { margin: 2px 0 0; font-weight: 700; }
.reporting-decision { display: grid; grid-template-columns: 1fr 180px minmax(220px, 1fr) auto; gap: 10px; align-items: center; padding: 18px; }
.reporting-decision p { margin: 3px 0 0; color: var(--app-muted); }
.notice-template-studio { display: grid; grid-template-columns: minmax(380px, .85fr) minmax(420px, 1.15fr); gap: 18px; }
.notice-template-preview { padding: 14px; background: #dce1e5; }
.notice-template-preview > div { position: relative; min-height: 700px; padding: 64px; border: 12px solid #fff; outline: 2px solid var(--template-accent); outline-offset: -22px; background: #fff; color: #24313b; }
.notice-template-preview > div::before { content: ''; position: absolute; inset: 0 0 auto; height: 12px; background: var(--template-primary); }
.notice-template-preview h2 { margin: 30px 0 8px; color: var(--template-primary); font-family: var(--app-title); font-size: 32px; letter-spacing: .3em; text-align: center; }
.notice-template-preview h3 { text-align: center; }
.notice-template-preview em { display: block; margin: 36px 0; color: #6f7780; font-size: 9px; font-style: normal; }
.notice-template-preview > div > p { min-height: 180px; line-height: 2; }
.notice-template-preview footer { display: flex; flex-direction: column; align-items: flex-end; margin-top: 35px; }
.notice-template-preview > div > i { position: absolute; right: 42px; bottom: 38px; width: 72px; height: 72px; display: grid; place-items: center; border: 1px dashed #9da7ae; color: #7b858c; font-size: 8px; font-style: normal; }
.notice-template-preview > p { color: #596672; font-size: 9px; }
.admin-core-workspace { display: grid; gap: 16px; }
.admin-core-workspace > .form-error { margin: 0; }
.issued-credential { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 24px; padding: 22px; border-left: 5px solid #d28b1d; background: #fff8e8; box-shadow: var(--app-shadow); }
.issued-credential h2, .issued-credential p { margin: 0; }
.issued-credential > div > span { color: #a06b13; font-size: 9px; letter-spacing: .16em; }
.issued-credential dl { display: flex; gap: 24px; margin: 0; }
.issued-credential dt { color: var(--app-muted); font-size: 9px; }
.issued-credential dd { margin: 3px 0 0; font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 800; }
.scope-banner-vue { display: flex; align-items: center; gap: 15px; padding: 18px 22px; color: #fff; background: var(--app-navy); }
.scope-banner-vue > span { padding: 7px 9px; background: var(--app-red); font-size: 9px; text-transform: uppercase; }
.scope-banner-vue > div { display: flex; flex-direction: column; }
.scope-banner-vue small { color: #aabfd0; }
.audit-ledger > header { padding: 16px 20px; }
.admin-create-strip > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.admin-create-strip > header h2, .admin-create-strip > header p { margin: 0; }
.check-row { display: flex; flex-wrap: wrap; gap: 18px; }
.check-row label { flex-direction: row !important; }
.excel-action-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 11px 14px; border: 1px solid var(--app-line); background: #edf2f5; }
.excel-action-bar a, .excel-action-bar label { cursor: pointer; padding: 7px 11px; border: 1px solid #aebdca; background: #fff; color: var(--app-navy); font-size: 9px; text-decoration: none; }
.organization-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 14px; }
.org-card > header { padding: 16px; }
.org-card > strong { display: block; padding: 12px 16px; color: var(--app-navy); font-size: 20px; }
.org-card > footer { padding: 12px 16px; border-top: 1px solid var(--app-line); }
.table-action { margin: 2px; padding: 6px 8px; border: 1px solid #b8c4cd; background: #fff; color: var(--app-navy); font-size: 9px; }
.table-action:disabled { opacity: .4; }
.quota-grid-vue { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 9px; }
.quota-grid-vue label { display: grid !important; grid-template-columns: 1fr 80px; align-items: center; padding: 12px; border: 1px solid var(--app-line); background: #f8fafb; }
.quota-grid-vue label > span { display: flex; flex-direction: column; }
.batch-ledger-vue { display: grid; gap: 12px; }
.batch-card-vue > header { padding: 16px 20px; }
.batch-card-vue > .chip-list, .batch-card-vue > .row-decision, .batch-card-vue > .app-button { margin: 14px 18px; }
.row-decision { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; min-width: 230px; }
.row-decision input { min-width: 150px; flex: 1; }
.row-decision button { padding: 6px 8px; border: 0; background: var(--app-navy); color: #fff; font-size: 9px; }
.archive-console-vue { display: grid; grid-template-columns: 1fr 120px 220px 120px auto; gap: 10px; align-items: end; padding: 19px; border-left: 5px solid #d28b1d; background: #fff8e8; }
.archive-console-vue h2, .archive-console-vue p { margin: 0; }
.archive-console-vue span { color: var(--app-muted); }
.admin-exam-workspace { display: grid; gap: 16px; }
.exam-builder-vue > header h2, .exam-builder-vue > header p { margin: 0; }
.exam-subject-builder { border: 1px solid var(--app-line); background: #f7f9fa; }
.exam-subject-builder > header { display: flex; justify-content: space-between; padding: 12px 15px; border-bottom: 1px solid var(--app-line); }
.exam-subject-builder > header button, .exam-subject-builder article > button { border: 0; background: transparent; color: var(--app-red); }
.exam-subject-builder article { margin: 12px; padding: 12px; border: 1px solid var(--app-line); background: #fff; }
.admin-exam-grid-vue { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; }
.admin-exam-grid-vue .exam-apply-card > footer { display: flex; align-items: center; justify-content: space-between; }
.arrangement-console-vue pre { max-height: 360px; overflow: auto; padding: 15px; background: #102941; color: #d6e5ef; font-size: 10px; white-space: pre-wrap; }
.result-exam-picker { display: flex; gap: 8px; overflow: auto; padding-bottom: 5px; }
.result-exam-picker button { min-width: 210px; display: flex; flex-direction: column; padding: 14px 16px; border: 1px solid var(--app-line); background: #fff; text-align: left; }
.result-exam-picker button.active { border-color: var(--app-red); box-shadow: inset 0 -3px var(--app-red); }
.result-exam-picker span { color: var(--app-blue); font-size: 9px; }
.result-exam-picker small { color: var(--app-muted); }
.result-entry-vue > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; }
.result-entry-vue > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 16px; }
.admin-admission-workspace, .admin-system-workspace { display: grid; gap: 16px; }
.admission-admin-setting > footer { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 14px; border-top: 1px solid var(--app-line); }
.plan-admin-row { display: grid; grid-template-columns: 1fr 120px 1fr 1fr auto; gap: 7px; }
.plan-admin-row > button { border: 0; background: transparent; color: var(--app-red); }
.notice-editor-vue > header, .workflow-design-grid-vue form > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.notice-editor-vue > header h2, .notice-editor-vue > header p { margin: 0; }
.check-inline { flex-direction: row !important; align-items: center; }
.room-editor-list { border: 1px solid var(--app-line); background: #f7f9fa; }
.room-editor-list > header { display: flex; justify-content: space-between; padding: 12px 15px; }
.room-editor-list > header button, .room-editor-list article > button { border: 0; background: transparent; color: var(--app-red); }
.room-editor-list article { margin: 0 12px 12px; padding: 12px; border: 1px solid var(--app-line); background: #fff; }
.workflow-grid-vue, .workflow-design-grid-vue { display: grid; grid-template-columns: repeat(auto-fit, minmax(390px, 1fr)); gap: 14px; }
.flow-card-vue > header { padding: 16px 18px; }
.workflow-track-vue { display: flex; overflow: auto; gap: 4px; padding: 16px; }
.workflow-track-vue > span { min-width: 115px; display: grid; grid-template-columns: 26px 1fr; grid-template-rows: auto auto; padding: 9px; color: var(--app-muted); background: #edf1f4; }
.workflow-track-vue i { grid-row: 1 / 3; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: #ccd5dd; font-style: normal; }
.workflow-track-vue span.done, .workflow-track-vue span.current { color: var(--app-navy); background: #e5f3ed; }
.workflow-track-vue span.done i, .workflow-track-vue span.current i { background: #24845a; color: #fff; }
.workflow-track-vue small { font-size: 8px; }
.flow-card-vue > footer { display: flex; align-items: center; gap: 5px; padding: 12px 16px; border-top: 1px solid var(--app-line); }
.flow-card-vue > footer > div { display: flex; flex: 1; flex-direction: column; }
.workflow-step-row-vue { display: grid; grid-template-columns: 32px 1fr 150px 30px; gap: 7px; align-items: center; }
.workflow-step-row-vue > b { display: grid; height: 30px; place-items: center; background: #e8eef3; }
.workflow-step-row-vue > button { border: 0; background: transparent; color: var(--app-red); }
.account-number-principle-vue { padding: 26px; color: #fff; background: var(--app-navy); }
.account-number-principle-vue span { color: #7eb0d4; font-size: 9px; }
.account-number-principle-vue h2 { margin: 5px 0; }
.account-number-principle-vue p { margin: 0; color: #bed0dd; }
.number-rule-layout-vue { display: grid; grid-template-columns: 1fr 330px; gap: 16px; }
.number-rule-layout-vue > aside { display: flex; flex-direction: column; justify-content: center; padding: 28px; background: #f0e8d8; }
.number-rule-layout-vue > aside > strong { margin: 12px 0; color: var(--app-red); font-family: ui-monospace, Consolas, monospace; font-size: 24px; overflow-wrap: anywhere; }
.rule-segment-grid { display: grid; gap: 8px; }
.rule-segment-grid label { display: grid !important; grid-template-columns: auto 1fr 100px; align-items: center; padding: 10px; border: 1px solid var(--app-line); }
.candidate-onboarding { min-height: 100vh; display: grid; grid-template-columns: 360px 1fr; }
.candidate-onboarding > aside { display: flex; flex-direction: column; padding: 45px; background: var(--app-navy); color: #fff; }
.candidate-onboarding > aside > p { margin-top: 90px; color: #85b2d7; font-size: 10px; }
.candidate-onboarding > aside > strong { font-family: ui-monospace, Consolas, monospace; font-size: 22px; }
.candidate-onboarding > aside > span { margin-top: 15px; color: #b5c7d8; font-size: 11px; line-height: 1.8; }
.candidate-onboarding > section { display: flex; align-items: center; justify-content: center; padding: 45px; }
.candidate-onboarding .business-form { width: min(760px, 100%); }
.onboarding-form { max-width: 520px; }
.app-toast { position: fixed; z-index: 100; right: 22px; bottom: 22px; min-width: 270px; display: flex; flex-direction: column; padding: 15px 18px; border-left: 4px solid #218252; background: #fff; box-shadow: 0 18px 45px rgba(8,32,58,.2); }
.app-toast.is-warning { border-color: #d28b1d; }
.app-toast.is-error { border-color: var(--app-red); }
.app-toast span { margin-top: 3px; color: var(--app-muted); font-size: 10px; }
.toast-enter-active, .toast-leave-active { transition: opacity .18s ease, transform .18s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
.app-modal-backdrop { position: fixed; z-index: 90; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(4,20,39,.66); }
.route-message { min-height: 100vh; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 30px; text-align: center; }
.route-message > span { color: var(--app-red); font-size: 12px; font-weight: 800; }
.route-message h1 { font-family: var(--app-title); }
.route-message p { color: var(--app-muted); }
.route-message button { padding: 10px 18px; border: 0; background: var(--app-navy); color: #fff; }
/* Administration workspace: restrained public-service typography and dense, readable ledgers. */
.portal-shell { width: 100%; overflow-x: clip; }
.portal-shell__main, .portal-shell__content, .admin-core-workspace, .record-panel { min-width: 0; }
.portal-shell__topbar { min-width: 0; }
.portal-shell__topbar > div:first-of-type { font-size: 13px; }
.portal-shell__user small { font-size: 12px; }
.portal-shell__user > button { min-height: 36px; padding: 0 8px; font-size: 13px; }
.portal-shell__brand strong { font-size: 17px; }
.portal-shell__brand small { font-size: 9px; }
.portal-shell__role { font-size: 13px; }
.portal-shell__sidebar nav section > strong { padding-top: 18px; font-size: 11px; }
.portal-shell__sidebar nav a { min-height: 43px; font-size: 14px; }
.portal-shell__sidebar nav a > span { font-size: 12px; }
.portal-shell__scope span, .portal-shell__scope small { font-size: 11px; }
.portal-shell__scope strong { font-size: 13px; }
.portal-page-heading { align-items: center; margin-bottom: 24px; padding-left: 17px; border-left: 4px solid var(--app-red); }
.portal-page-heading p { margin-bottom: 5px; font-size: 11px; }
.portal-page-heading h1 { font-size: clamp(28px, 2.3vw, 34px); line-height: 1.25; }
.portal-page-heading span { margin-top: 7px; font-size: 13px; }
.portal-shell__content .record-metrics article span,
.portal-shell__content .dashboard-row small,
.portal-shell__content .notice-list-vue small,
.portal-shell__content .page-state p,
.portal-shell__content .form-callout p,
.portal-shell__content .exam-apply-card > p,
.portal-shell__content .registration-vue-card > p,
.portal-shell__content .reporting-workbench small,
.portal-shell__content .scan-preview dt,
.portal-shell__content .issued-credential dt,
.portal-shell__content .workflow-track-vue small { font-size: 12px; line-height: 1.5; }
.portal-shell__content .exam-apply-card > header > span,
.portal-shell__content .registration-vue-card header span,
.portal-shell__content .admit-card-vue header span,
.portal-shell__content .result-card-grid article > span,
.portal-shell__content .result-exam-picker span,
.portal-shell__content .admission-export-bar > div > span,
.portal-shell__content .issued-credential > div > span,
.portal-shell__content .scope-banner-vue > span { font-size: 11px; }
.portal-shell__content .exam-apply-card dt,
.portal-shell__content .registration-vue-card dt,
.portal-shell__content .admit-card-vue dt { font-size: 12px; }
.portal-shell__content .exam-apply-card dd,
.portal-shell__content .registration-vue-card dd,
.portal-shell__content .admit-card-vue dd { font-size: 14px; }
.portal-shell__content .chip-list > span { font-size: 12px; }
.portal-shell__content .chip-list small { font-size: 11px; }
.portal-shell__content .result-card-grid form button { font-size: 12px; }
.app-button { border-radius: 4px; font-size: 13px; }
.business-form, .record-panel { border-color: #d4dee7; border-radius: 4px; box-shadow: 0 5px 18px rgba(13, 45, 84, .045); }
.business-form { padding: 24px; }
.auth-card h2, .business-form h2 { font-size: 28px; line-height: 1.35; }
.auth-card > span, .business-form > span { font-size: 13px; }
.auth-card label, .business-form label { color: #425469; font-size: 13px; font-weight: 650; }
.auth-card input, .auth-card select, .business-form input:not([type='checkbox']):not([type='radio']), .business-form select, .business-form textarea, .preference-row select {
border-radius: 3px;
font-size: 14px;
transition: border-color .16s ease, box-shadow .16s ease;
}
.auth-card input:focus, .auth-card select:focus, .business-form input:focus, .business-form select:focus, .business-form textarea:focus {
border-color: #4b7da7;
box-shadow: 0 0 0 3px rgba(23, 85, 143, .10);
}
.admin-create-strip { padding: 24px 26px; }
.admin-create-strip > header { margin-bottom: 18px; }
.admin-create-strip > header p { color: var(--app-blue); font-size: 11px; font-weight: 800; letter-spacing: .16em; }
.admin-create-strip .form-grid { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 15px 18px; }
.admin-create-strip .form-grid label { margin: 0; }
.admin-create-strip > .app-button { margin-top: 18px; }
.check-row { align-items: center; gap: 12px 24px; margin-top: 18px; }
.check-row label { align-items: center; gap: 9px; margin: 0; font-size: 13px; cursor: pointer; }
.business-form input[type='checkbox'], .business-form input[type='radio'] {
width: 18px;
height: 18px;
min-height: 18px;
flex: 0 0 18px;
margin: 0;
padding: 0;
accent-color: var(--app-blue);
}
.record-panel > header { min-height: 70px; padding: 14px 20px; }
.record-panel > header h2 { font-size: 19px; line-height: 1.4; }
.record-panel > header p { font-size: 12px; line-height: 1.5; }
.record-panel > header input, .record-panel > header select,
.ledger-toolbar input, .ledger-toolbar select,
.archive-console-vue select, .row-decision input {
min-height: 40px;
padding: 8px 11px;
border: 1px solid #c7d3de;
border-radius: 3px;
background: #fff;
color: var(--app-ink);
font-size: 13px;
}
.record-panel > header input { width: min(330px, 42vw); }
.record-panel > header input::placeholder, .row-decision input::placeholder { color: #8795a4; }
.status-badge { padding: 4px 9px; font-size: 12px; font-weight: 700; }
.table-scroll { width: 100%; min-width: 0; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-color: #aebdca #eef2f5; }
.table-scroll table { width: 100%; min-width: 760px; border-spacing: 0; border-collapse: separate; color: #26384b; font-size: 14px; }
.table-scroll th, .table-scroll td { padding: 12px 14px; border-bottom: 1px solid #e1e7ed; text-align: left; vertical-align: middle; }
.table-scroll th { position: relative; background: #edf3f7; color: #324a61; font-size: 13px; font-weight: 750; white-space: nowrap; }
.table-scroll tbody tr:nth-child(even) td { background: #fbfcfd; }
.table-scroll tbody tr:hover td { background: #f2f7fb; }
.table-scroll tbody tr:last-child td { border-bottom: 0; }
.table-scroll td > strong { display: block; color: #152f4d; font-weight: 750; }
.table-scroll td > small { display: block; margin-top: 3px; color: #66778a; font-size: 12px; line-height: 1.45; }
.table-scroll td:last-child { white-space: nowrap; }
.table-empty { height: 120px; color: var(--app-muted); text-align: center !important; }
.table-action { min-height: 32px; margin: 2px; padding: 0 10px; border-radius: 3px; font-size: 12px; font-weight: 650; cursor: pointer; }
.table-action:hover:not(:disabled) { border-color: var(--app-blue); background: #eef5fb; color: var(--app-blue); }
.row-decision { min-width: 370px; flex-wrap: nowrap; gap: 6px; }
.row-decision input { min-width: 145px; }
.row-decision button { min-height: 34px; padding: 0 10px; border-radius: 3px; font-size: 12px; font-weight: 700; cursor: pointer; white-space: nowrap; }
.row-decision button:first-of-type { background: #fff; color: var(--app-red); box-shadow: inset 0 0 0 1px #d5a6aa; }
.excel-action-bar { gap: 9px; padding: 12px 14px; border-radius: 4px; }
.excel-action-bar a, .excel-action-bar label { min-height: 36px; display: inline-flex; align-items: center; padding: 0 13px; border-radius: 3px; font-size: 12px; font-weight: 650; }
.candidate-ledger__toolbar { grid-template-columns: minmax(230px, 1.3fr) minmax(180px, .9fr) minmax(150px, .7fr) 120px; gap: 12px; padding: 14px 20px; }
.candidate-ledger__toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #526477; font-size: 12px; font-weight: 650; }
.candidate-ledger__toolbar input, .candidate-ledger__toolbar select { width: 100%; }
.candidate-ledger table { min-width: 1040px; }
.candidate-ledger th:last-child { min-width: 390px; }
.ledger-pagination { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 20px; border-top: 1px solid var(--app-line); background: #f8fafc; color: var(--app-muted); font-size: 13px; }
.ledger-pagination > div { display: flex; gap: 8px; }
.ledger-pagination button { min-height: 34px; padding: 0 13px; border: 1px solid #b9c8d5; border-radius: 3px; background: #fff; color: var(--app-navy); font-size: 12px; font-weight: 650; cursor: pointer; }
.ledger-pagination button:disabled { opacity: .45; cursor: not-allowed; }
.auth-view { overflow: hidden; }
.auth-view__identity { position: relative; isolation: isolate; }
.auth-view__identity::after { content: ''; position: absolute; z-index: -1; right: -110px; bottom: -150px; width: 360px; height: 360px; border: 1px solid rgba(255,255,255,.09); border-radius: 50%; box-shadow: 0 0 0 48px rgba(255,255,255,.025), 0 0 0 96px rgba(255,255,255,.018); }
.auth-view__identity > div p { font-size: 11px; }
.auth-view__identity h1 { max-width: 500px; font-size: clamp(36px, 3vw, 44px); }
.auth-view__identity h1 > span { display: block; white-space: nowrap; }
.auth-view__identity > div > span, .auth-view__identity > small { font-size: 13px; }
.auth-view__panel { position: relative; background: linear-gradient(135deg, #fff 0%, #f9fbfd 100%); }
.auth-view__back { position: absolute; top: 34px; left: 6vw; font-size: 13px; }
.auth-card { padding: 30px 32px 32px; border-top: 4px solid var(--app-navy); background: #fff; box-shadow: 0 18px 55px rgba(13,45,84,.12); }
.auth-card > p { font-size: 11px; }
.auth-card__switch { margin: 18px 0 0 !important; font-size: 13px; }
@media (min-width: 761px) {
html.auth-login-active, html.auth-login-active body, html.auth-login-active #app { height: 100%; overflow: hidden; }
.auth-view--login { height: 100dvh; min-height: 0; }
.auth-view--login .auth-view__identity, .auth-view--login .auth-view__panel { height: 100%; min-height: 0; }
.auth-view--login .auth-view__panel { padding-block: 32px; }
}
@media (max-width: 960px) {
.public-frame__header > .app-container { grid-template-columns: 1fr auto; }
.public-frame__menu { display: block; }
.public-frame__header nav { position: absolute; top: 76px; right: 0; left: 0; display: none; align-items: stretch; flex-direction: column; padding: 12px 20px; border-bottom: 1px solid var(--app-line); background: #fff; }
.public-frame__header nav.is-open { display: flex; }
.public-directory { grid-template-columns: 190px 1fr; gap: 25px; }
.auth-view { grid-template-columns: 330px 1fr; }
.auth-view__identity { padding-inline: 36px; }
.portal-shell__sidebar { width: 220px; }
.portal-shell__main { margin-left: 220px; }
.candidate-dashboard-grid, .security-stack, .admission-dashboard-grid, .notice-template-studio { grid-template-columns: 1fr; }
.ledger-toolbar { grid-template-columns: 1fr; }
.candidate-ledger__toolbar { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
.app-container { width: calc(100% - 28px); }
.app-brand small { display: none; }
.public-frame__utility .app-container span:last-child { display: none; }
.public-frame__footer .app-container { align-items: flex-start; flex-direction: column; justify-content: center; }
.public-directory { display: block; }
.public-directory__filters { margin-bottom: 24px; }
.directory-list > button { grid-template-columns: 54px 1fr auto; gap: 12px; }
.public-document > header { padding-inline: 22px; }
.public-document > header h1 { font-size: 25px; }
.public-document > section { padding-inline: 18px; }
.verification-form { grid-template-columns: 1fr; }
.auth-view { grid-template-columns: 1fr; }
.auth-view__identity { min-height: auto; padding: 28px; }
.auth-view__identity > div { margin: 65px 0; }
.auth-view__identity h1 { font-size: 38px; }
.auth-view__panel { min-height: 650px; padding: 30px 22px; }
.portal-shell__sidebar { width: min(285px, 86vw); transform: translateX(-105%); transition: transform .18s ease; }
.portal-shell__sidebar.is-open { transform: translateX(0); }
.portal-shell__close { position: absolute; top: 18px; right: 14px; display: block; border: 0; background: transparent; color: #fff; font-size: 23px; }
.portal-shell__scrim { position: fixed; z-index: 45; inset: 0; background: rgba(4,20,39,.45); }
.portal-shell__main { margin-left: 0; }
.portal-shell__topbar { grid-template-columns: auto 1fr auto; gap: 12px; padding: 0 14px; }
.portal-shell__topbar > button { display: block; border: 0; background: transparent; color: var(--app-navy); font-size: 19px; }
.portal-shell__topbar > div:first-of-type span, .portal-shell__topbar > div:first-of-type b { display: none; }
.portal-shell__user > span { display: none; }
.portal-shell__content { padding: 20px 14px; }
.portal-page-heading { padding-left: 13px; }
.record-panel > header { align-items: flex-start; flex-direction: column; }
.record-panel > header input { width: 100%; }
.candidate-ledger__toolbar { grid-template-columns: 1fr; }
.ledger-pagination { align-items: stretch; flex-direction: column; }
.ledger-pagination > div, .ledger-pagination button { flex: 1; }
.form-grid, .preference-row { grid-template-columns: 1fr; }
.totp-setup-grid { grid-template-columns: 1fr; }
.totp-setup-grid img { max-width: 220px; }
.preference-row > b { min-height: 30px; }
.candidate-onboarding { grid-template-columns: 1fr; }
.candidate-onboarding > aside { padding: 26px; }
.candidate-onboarding > aside > p { margin-top: 55px; }
.candidate-onboarding > section { padding: 24px 14px; }
.allocation-row, .reporting-tools, .reporting-decision { grid-template-columns: 1fr; }
.admission-export-bar { align-items: stretch; flex-direction: column; }
.scan-preview dl { grid-template-columns: 1fr 1fr; }
.notice-template-preview > div { min-height: 600px; padding: 42px 30px; }
.issued-credential, .archive-console-vue { grid-template-columns: 1fr; align-items: stretch; }
.issued-credential dl { flex-direction: column; gap: 8px; }
.plan-admin-row, .number-rule-layout-vue { grid-template-columns: 1fr; }
.workflow-grid-vue, .workflow-design-grid-vue { grid-template-columns: 1fr; }
.workflow-step-row-vue { grid-template-columns: 30px 1fr; }
}
.center-edit-picker .chip-list button { border: 1px solid var(--app-line); border-radius: 999px; padding: 8px 13px; background: #fff; color: var(--app-navy); cursor: pointer; }
.center-edit-picker .chip-list button.active { border-color: var(--app-blue); background: #e9f2fb; color: var(--app-blue); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
@@ -0,0 +1,76 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { api } from '@/lib/api';
import { uiStore } from '@/stores/ui';
import { specialtyCatalog, specialtyTypesFor } from '@/data/specialties';
const props = defineProps({ page: { type: String, required: true }, data: { type: Object, default: () => ({}) } });
const emit = defineEmits(['reload']);
const busy = ref(false);
const error = ref('');
const issued = ref(null);
const search = ref('');
const examFilter = ref('');
const notes = reactive({});
const setting = reactive({ examId: '', preferenceStart: '', preferenceEnd: '', status: 'draft', maxChoices: 5, maxSubmissions: 3, progress: '', enabled: false, autoPublish: true });
const account = reactive({ schoolId: '', username: '', password: '', displayName: '' });
const plan = reactive({ examId: '', schoolId: '', note: '', categories: [{ name: '普通生', quota: 1, specialtyCategory: '', specialtyType: '' }] });
const selectedSetting = computed(() => props.data.settings?.find(item => item.examId === setting.examId));
const placements = computed(() => (props.data.placements || []).filter(item => (!examFilter.value || item.examId === examFilter.value) && (!search.value || JSON.stringify(item).toLowerCase().includes(search.value.toLowerCase()))));
const preferences = computed(() => (props.data.preferenceRows || []).filter(item => (!examFilter.value || item.examId === examFilter.value) && (!search.value || JSON.stringify(item).toLowerCase().includes(search.value.toLowerCase()))));
function hydrateSetting() {
const current = selectedSetting.value || props.data.settings?.[0];
if (!current) { setting.examId ||= props.data.exams?.[0]?.id || ''; return; }
Object.assign(setting, { examId: current.examId, preferenceStart: current.payload?.preferenceStart?.slice(0,16) || '', preferenceEnd: current.payload?.preferenceEnd?.slice(0,16) || '', status: current.status || 'draft', maxChoices: current.payload?.maxChoices || 5, maxSubmissions: current.payload?.maxSubmissions || 3, progress: current.payload?.progress || '', enabled: Boolean(current.payload?.enabled), autoPublish: current.payload?.autoPublish !== false });
}
function addCategory() { plan.categories.push({ name: '', quota: 1, specialtyCategory: '', specialtyType: '' }); }
function resetSpecialty(category) { category.specialtyType = ''; }
async function act(action, success, reload = true) {
busy.value = true; error.value = '';
try { const result = await action(); if (success) uiStore.notify(success); if (reload) emit('reload'); return result; }
catch (requestError) { error.value = requestError.message; return null; }
finally { busy.value = false; }
}
function saveSetting() { act(() => api(`/api/admin/admissions/${setting.examId}/setting`, { method: 'PUT', body: { ...setting, maxChoices: Number(setting.maxChoices), maxSubmissions: Number(setting.maxSubmissions), preferenceStart: setting.preferenceStart ? new Date(setting.preferenceStart).toISOString() : '', preferenceEnd: setting.preferenceEnd ? new Date(setting.preferenceEnd).toISOString() : '' } }), '志愿设置已保存'); }
function runAdmission(actionName) {
const labels = { match: '按规则投档', finalize: '签发通知书并开启报到', supplementary: '开启补录' };
if (!window.confirm(`确认执行“${labels[actionName]}”吗?该操作会改变本场录取状态。`)) return;
act(() => api(`/api/admin/admissions/${setting.examId}/${actionName}`, { method: 'POST', body: {} }), `${labels[actionName]}已完成`);
}
async function createAccount() { const result = await act(() => api('/api/admin/admission-school-accounts', { method: 'POST', body: account }), '招生学校账户已创建', false); if (result) { issued.value = result.temporaryPassword ? { account: result.username, password: result.temporaryPassword } : null; emit('reload'); } }
function toggleAccount(item) { act(() => api(`/api/admin/admission-school-accounts/${item.id}`, { method: 'PATCH', body: { active: !item.active } }), item.active ? '招生账户已停用' : '招生账户已启用'); }
async function resetAccount(item) { const result = await act(() => api(`/api/admin/admission-school-accounts/${item.id}/reset-password`, { method: 'POST' }), '', false); if (result) issued.value = { account: result.username, password: result.temporaryPassword }; }
function submitPlan() {
const categories = plan.categories.map((item, index) => ({ code: `category_${index + 1}`, name: item.name.trim(), quota: Number(item.quota), isSpecialty: Boolean(item.specialtyCategory), specialtyCategory: item.specialtyCategory, specialtyType: item.specialtyType, indicatorAllocations: [] })).filter(item => item.name && item.quota > 0);
if (categories.some(item => item.isSpecialty && !item.specialtyType)) { error.value = '特长生类别必须选择具体特长项目'; return; }
act(() => api('/api/admin/admission-plans', { method: 'POST', body: { examId: plan.examId, schoolId: plan.schoolId, note: plan.note, categories } }), '招生计划已代上传并通过');
}
function reviewPlan(item, status) { act(() => api(`/api/admin/admission-plans/${item.id}`, { method: 'PATCH', body: { status, reviewNote: notes[item.id] || '' } }), status === 'approved' ? '招生计划已通过' : '招生计划已退回'); }
function reviewReporting(item, approved) {
const preferenceEnd = approved && item.payload?.supplementDecision === 'supplement' ? window.prompt('补录志愿结束时间(ISO 或本地日期时间)', '') || '' : '';
if (approved && item.payload?.supplementDecision === 'supplement' && !preferenceEnd) return;
act(() => api(`/api/admin/admission-reporting/${item.id}`, { method: 'PATCH', body: { approved, approvalNote: notes[item.id] || '', preferenceEnd } }), approved ? '报到与补录决定已批准' : '报到决定已退回');
}
function reviewWithdrawal(item, approved) { act(() => api(`/api/admin/admission-withdrawals/${item.id}`, { method: 'PATCH', body: { approved, reviewNote: notes[item.id] || '' } }), approved ? '退档申请已批准' : '退档申请已驳回'); }
hydrateSetting();
</script>
<template>
<div class="admin-admission-workspace">
<div v-if="error" class="form-error">{{ error }}</div>
<section v-if="issued" class="issued-credential"><div><span>ONE-TIME CREDENTIAL</span><h2>招生学校临时密码</h2><p>关闭后不再展示,请安全交付。</p></div><dl><div><dt>账号</dt><dd>{{ issued.account }}</dd></div><div><dt>临时密码</dt><dd>{{ issued.password }}</dd></div></dl><button class="app-button" @click="issued = null">我已保存</button></section>
<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>志愿内容仅超级管理员可见且不可代改投档和录取变更全部进入审计日志</p></div></section>
<template v-if="page === 'admission-settings'"><form class="business-form admission-admin-setting" @submit.prevent="saveSetting"><p>EXAM PREFERENCE SETTING</p><h2>考试志愿与录取阶段</h2><label><span>考试</span><select v-model="setting.examId" @change="hydrateSetting"><option v-for="exam in data.exams" :key="exam.id" :value="exam.id">{{ exam.name }}</option></select></label><div class="form-grid"><label><span>填报开始</span><input v-model="setting.preferenceStart" type="datetime-local"></label><label><span>填报结束</span><input v-model="setting.preferenceEnd" type="datetime-local"></label><label><span>当前阶段</span><select v-model="setting.status"><option v-for="(label, value) in {draft:'草稿',filling:'志愿填报中',closed:'填报截止',matching:'投档中',school_review:'学校审核',reporting:'考生报到',supplementary:'补录填报',completed:'录取完成'}" :key="value" :value="value">{{ label }}</option></select></label><label><span>普通志愿数</span><input v-model="setting.maxChoices" type="number" min="1" max="20"></label><label><span>最多提交次数</span><input v-model="setting.maxSubmissions" type="number" min="1" max="50"></label><label><span>考生进度说明</span><input v-model="setting.progress"></label></div><div class="check-row"><label><input v-model="setting.enabled" type="checkbox"> 启用志愿填报</label><label><input v-model="setting.autoPublish" type="checkbox"> 完成后自动公示</label></div><button class="app-button app-button--primary">保存志愿设置</button><footer><button class="app-button" type="button" @click="runAdmission('match')">按规则投档</button><button class="app-button" type="button" @click="runAdmission('finalize')">签发通知书并开启报到</button><button class="app-button" type="button" @click="runAdmission('supplementary')">开启补录</button></footer></form><section class="record-metrics"><article><span>待审计划</span><strong>{{ data.plans?.filter(item => item.status === 'pending').length || 0 }}</strong></article><article><span>学校审核中</span><strong>{{ data.placements?.filter(item => item.status === 'school_review').length || 0 }}</strong></article><article><span>退档待审</span><strong>{{ data.placements?.filter(item => item.status === 'withdrawal_pending').length || 0 }}</strong></article><article><span>正式录取</span><strong>{{ data.placements?.filter(item => item.status === 'final').length || 0 }}</strong></article></section></template>
<template v-else-if="page === 'admission-accounts'"><form class="business-form" @submit.prevent="createAccount"><p>ADMISSION SCHOOL ACCOUNT</p><h2>创建招生学校账户</h2><div class="form-grid"><label><span>招生学校</span><select v-model="account.schoolId" required><option value="">请选择</option><option v-for="school in (data.admissionSchools || data.schools)" :key="school.id" :value="school.id">{{ school.code }} · {{ school.name }}</option></select></label><label><span>显示名称</span><input v-model="account.displayName" placeholder="学校招生办公室"></label><label><span>登录账号</span><input v-model="account.username" required></label><label><span>初始密码</span><input v-model="account.password" type="password" minlength="8" required></label></div><button class="app-button app-button--primary">创建招生账户</button></form><section class="record-panel"><header><div><h2>招生学校账户台账</h2><p>{{ data.schoolAccounts?.length || 0 }} 个</p></div></header><div class="table-scroll"><table><thead><tr><th>显示名称</th><th>账号</th><th>学校</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.schoolAccounts" :key="item.id"><td>{{ item.displayName }}</td><td>{{ item.username }}</td><td>{{ item.schoolName }}<small>{{ item.schoolCode }}</small></td><td><StatusBadge :value="item.active ? 'active' : 'disabled'" /></td><td><button class="table-action" @click="resetAccount(item)">重置密码</button><button class="table-action" @click="toggleAccount(item)">{{ item.active ? '停用' : '启用' }}</button></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'admission-plans'"><form class="business-form" @submit.prevent="submitPlan"><p>PLAN ON BEHALF</p><h2>代上传招生计划</h2><div class="form-grid"><label><span>考试</span><select v-model="plan.examId" required><option v-for="exam in data.exams" :key="exam.id" :value="exam.id">{{ exam.name }}</option></select></label><label><span>招生学校</span><select v-model="plan.schoolId" required><option v-for="school in (data.admissionSchools || data.schools)" :key="school.id" :value="school.id">{{ school.name }}</option></select></label></div><div v-for="(category, index) in plan.categories" :key="index" class="plan-admin-row"><input v-model="category.name" required placeholder="类别名称"><input v-model="category.quota" type="number" min="1" required placeholder="计划人数"><select v-model="category.specialtyCategory" @change="resetSpecialty(category)"><option value="">普通 / 政策类</option><option v-for="item in specialtyCatalog" :key="item.code" :value="item.code">{{ item.name }}特长生</option></select><select v-model="category.specialtyType" :disabled="!category.specialtyCategory"><option value="">选择特长项目</option><option v-for="item in specialtyTypesFor(category.specialtyCategory)" :key="item[0]" :value="item[0]">{{ item[1] }}</option></select><button type="button" @click="plan.categories.splice(index,1)">移除</button></div><button class="app-button" type="button" @click="addCategory"> 添加类别</button><label><span>计划说明</span><textarea v-model="plan.note"></textarea></label><button class="app-button app-button--primary">代上传并审核通过</button></form><section class="record-panel"><header><div><h2>招生计划审核台账</h2><p>{{ data.plans?.filter(item => item.status === 'pending').length || 0 }} 份待审</p></div></header><div class="table-scroll"><table><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>状态</th><th>审核</th></tr></thead><tbody><tr v-for="item in data.plans" :key="item.id"><td>{{ item.examName }}<small>{{ item.schoolName }}</small></td><td><span v-for="category in item.payload?.categories" :key="category.code" class="table-stack">{{ category.name }} {{ category.quota }} 人</span></td><td><StatusBadge :value="item.status" /></td><td><div v-if="item.status === 'pending'" class="row-decision"><input v-model="notes[item.id]" placeholder="审核意见"><button @click="reviewPlan(item,'rejected')">退回</button><button @click="reviewPlan(item,'approved')">通过</button></div><span v-else>{{ item.payload?.reviewNote }}</span></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'admission-reporting'"><section class="record-panel"><header><div><h2>学校报到与补录决定</h2><p>批准后按决定公开报到统计或进入补录阶段。</p></div></header><div class="table-scroll"><table><thead><tr><th>考试 / 学校</th><th>轮次</th><th>报到统计</th><th>学校决定</th><th>状态</th><th>审批</th></tr></thead><tbody><tr v-for="item in data.reportingRequests" :key="item.id"><td>{{ item.examName }}<small>{{ item.schoolName }}</small></td><td>第 {{ item.payload?.round || 1 }} 轮</td><td>计划 {{ item.progress?.totalQuota }} · 报到 {{ item.progress?.reportedCount }} · 缺额 {{ item.progress?.reportingGap }}</td><td>{{ item.payload?.supplementDecision === 'supplement' ? '申请补录' : '不补录' }}<small>{{ item.payload?.decisionNote }}</small></td><td><StatusBadge :value="item.status" /></td><td><div v-if="item.status === 'pending_approval'" class="row-decision"><input v-model="notes[item.id]" placeholder="审批意见"><button @click="reviewReporting(item,false)">退回</button><button @click="reviewReporting(item,true)">批准</button></div><span v-else>{{ item.payload?.approvalNote }}</span></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'admission-supervision'"><div class="ledger-toolbar"><input v-model="search" placeholder="搜索考生、报名号、学校、类别"><select v-model="examFilter"><option value="">全部考试</option><option v-for="exam in data.exams" :key="exam.id" :value="exam.id">{{ exam.name }}</option></select><a class="app-button" :href="`/api/admin/admissions/placements/export?examId=${encodeURIComponent(examFilter)}`">导出投档台账</a><a class="app-button" :href="`/api/admin/admissions/preferences/export?examId=${encodeURIComponent(examFilter)}`">导出志愿快照</a></div><section class="record-panel"><header><div><h2>投档与退档监督</h2><p>投档记录可审核特殊退档,考生志愿保持只读。</p></div></header><div class="table-scroll"><table><thead><tr><th>考生</th><th>考试 / 分数</th><th>投档学校</th><th>类别 / 志愿</th><th>状态</th><th>退档审批</th></tr></thead><tbody><tr v-for="item in placements" :key="item.id"><td>{{ item.candidate?.name }}<small>{{ item.candidate?.registrationNumber }}</small></td><td>{{ item.examName }}<small>{{ item.payload?.totalScore }} 分</small></td><td>{{ item.schoolName }}</td><td>{{ item.payload?.categoryName }} · 第 {{ item.payload?.preferenceOrder }} 志愿</td><td><StatusBadge :value="item.status" /></td><td><div v-if="item.status === 'withdrawal_pending'" class="row-decision"><input v-model="notes[item.id]" :placeholder="item.payload?.withdrawalReason || '审批意见'"><button @click="reviewWithdrawal(item,false)">驳回</button><button @click="reviewWithdrawal(item,true)">批准</button></div></td></tr></tbody></table></div></section><section class="record-panel"><header><div><h2>考生志愿实时快照</h2><p>{{ preferences.length }} 人;仅监督和导出,不提供代改入口。</p></div></header><div class="table-scroll"><table><thead><tr><th>考生 / 报名号</th><th>考试 / 生源校</th><th>轮次 / 状态</th><th>志愿顺序</th><th>提交次数</th></tr></thead><tbody><tr v-for="item in preferences" :key="`${item.examId}-${item.candidate?.registrationNumber}`"><td>{{ item.candidate?.name }}<small>{{ item.candidate?.registrationNumber }}</small></td><td>{{ item.examName }}<small>{{ item.sourceSchoolName }}</small></td><td>第 {{ item.round }} 轮 · {{ item.fillStatus }}</td><td><span v-for="(choice,index) in item.choices" :key="index" class="table-stack">{{ choice.preferenceType === 'indicator' ? '指标' : index + 1 }} · {{ choice.schoolName }} · {{ choice.categoryName }}</span></td><td>{{ item.submissionCount }} / {{ item.maxSubmissions }}</td></tr></tbody></table></div></section></template>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import AccountSecurity from '@/components/common/AccountSecurity.vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { api } from '@/lib/api';
import { sessionStore } from '@/stores/session';
import { uiStore } from '@/stores/ui';
const props = defineProps({ page: { type: String, required: true }, data: { type: Object, default: () => ({}) } });
const emit = defineEmits(['reload']);
const busy = ref(false);
const error = ref('');
const issued = ref(null);
const schoolForm = reactive({ name: '', code: '', address: '', isSourceSchool: true, isAdmissionSchool: false, active: true });
const classForm = reactive({ grade: '', name: '', active: true });
const adminForm = reactive({ displayName: '', username: '', password: '', adminLevel: 'school', schoolId: '', classId: '' });
const archiveForm = reactive({ scopeType: 'class', scopeValue: '', archived: true });
const reviewNotes = reactive({});
const batchQuotas = reactive({});
const query = ref('');
const candidateSchool = ref('');
const candidateStatus = ref('');
const candidatePage = ref(1);
const candidatePageSize = ref(20);
const filtered = computed(() => {
const candidates = props.data.candidates || [];
const needle = query.value.trim().toLowerCase();
return candidates.filter(item => {
if (needle && !JSON.stringify(item).toLowerCase().includes(needle)) return false;
if (candidateSchool.value && item.school !== candidateSchool.value) return false;
if (candidateStatus.value && item.status !== candidateStatus.value) return false;
return true;
});
});
const candidateSchools = computed(() => [...new Set((props.data.candidates || []).map(item => item.school).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')));
const candidateStatuses = computed(() => [...new Set((props.data.candidates || []).map(item => item.status).filter(Boolean))]);
const candidatePageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / candidatePageSize.value)));
const pagedCandidates = computed(() => {
const start = (candidatePage.value - 1) * candidatePageSize.value;
return filtered.value.slice(start, start + candidatePageSize.value);
});
const availableClasses = computed(() => {
if (props.page === 'organization' || sessionStore.state.user?.adminLevel === 'school') return props.data.classes || [];
return (props.data.classes || []).filter(item => !adminForm.schoolId || item.schoolId === adminForm.schoolId);
});
watch([query, candidateSchool, candidateStatus, candidatePageSize], () => { candidatePage.value = 1; });
watch(candidatePageCount, count => { if (candidatePage.value > count) candidatePage.value = count; });
watch(() => props.page, () => { candidatePage.value = 1; });
function candidateStatusLabel(value) {
return ({ pending: '待审核', school_review: '学校审核', approved: '已通过', rejected: '已退回' })[value] || value;
}
async function act(action, success, reload = true) {
busy.value = true; error.value = '';
try { const result = await action(); if (success) uiStore.notify(success); if (reload) emit('reload'); return result; }
catch (requestError) { error.value = requestError.message; return null; }
finally { busy.value = false; }
}
function createSchool() { act(() => api('/api/admin/schools', { method: 'POST', body: schoolForm }), '学校档案已创建'); }
function toggleSchool(item) { act(() => api(`/api/admin/schools/${item.id}`, { method: 'PATCH', body: { active: !item.active } }), item.active ? '学校已停用' : '学校已启用'); }
function createClass() { act(() => api('/api/admin/classes', { method: 'POST', body: classForm }), '班级已创建'); }
function toggleClass(item) { act(() => api(`/api/admin/classes/${item.id}`, { method: 'PATCH', body: { active: !item.active } }), item.active ? '班级已停用' : '班级已启用'); }
function createAdmin() {
const body = { ...adminForm };
if (sessionStore.state.user?.adminLevel === 'school') Object.assign(body, { adminLevel: 'class', schoolId: sessionStore.state.user.schoolId });
act(() => api('/api/admin/admins', { method: 'POST', body }), '管理员已创建');
}
function toggleAdmin(item) { act(() => api(`/api/admin/admins/${item.id}`, { method: 'PATCH', body: { active: !item.active, classId: item.classId } }), item.active ? '管理员已停用' : '管理员已启用'); }
async function resetAdmin(item) { const result = await act(() => api(`/api/admin/admins/${item.id}/reset-password`, { method: 'POST' }), '', false); if (result) issued.value = { title: '管理员临时密码', account: result.username, password: result.temporaryPassword }; }
function setSelfRegistration(enabled) { act(() => api('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled } }), enabled ? '自主注册已开启' : '自主注册已关闭'); }
function submitBatch() {
const quotas = (props.data.classes || []).map(item => ({ classId: item.id, count: Number(batchQuotas[item.id] || 0) })).filter(item => item.count > 0);
if (!quotas.length) { error.value = '请至少为一个班级填写申领数量'; return; }
act(() => api('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas } }), '批量报名号申领已提交');
}
function reviewBatch(batch, status) { act(() => api(`/api/admin/candidate-account-batches/${batch.id}`, { method: 'PATCH', body: { status, reviewNote: reviewNotes[batch.id] || '' } }), status === 'approved' ? '批次审批已通过' : '批次已退回'); }
function reviewCandidate(item, status) { act(() => api(`/api/admin/candidates/${item.id}`, { method: 'PATCH', body: { status, reviewNote: reviewNotes[item.id] || '' } }), status === 'approved' ? '考生资料已通过当前步骤' : '考生资料已退回'); }
async function resetCandidate(item) { const result = await act(() => api(`/api/admin/candidates/${item.id}/reset-password`, { method: 'POST' }), '', false); if (result) issued.value = { title: '考生临时密码', account: result.candidateNumber, password: result.temporaryPassword }; }
function archiveCandidates() { act(() => api('/api/admin/candidate-accounts/archive', { method: 'POST', body: archiveForm }), archiveForm.archived ? '范围内账户已归档' : '范围内账户已恢复'); }
function reviewRegistration(item, status) { act(() => api(`/api/admin/registrations/${item.id}`, { method: 'PATCH', body: { status, reviewNote: reviewNotes[item.id] || '' } }), status === 'approved' ? '考试报名已通过当前步骤' : '考试报名已退回'); }
function updatePayment(item, status) { act(() => api(`/api/admin/payments/${item.id}`, { method: 'PATCH', body: { status } }), '缴费状态已更新'); }
function saveQualification(group, item, eligible) { act(() => api(`/api/admin/indicator-qualifications/${group.exam.id}/${item.userId}`, { method: 'PUT', body: { eligible } }), '指标资格已确认'); }
function bulkQualification(group, eligible) { const userIds = (group.qualificationStatus?.rows || []).map(item => item.userId); act(() => api(`/api/admin/indicator-qualifications/${group.exam.id}/bulk`, { method: 'PUT', body: { userIds, eligible } }), '本场指标资格已批量确认'); }
async function importExcel(resource, event) {
const file = event.target.files?.[0]; if (!file) return;
await act(() => api(`/api/admin/excel/${resource}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: file }), 'Excel 已导入');
event.target.value = '';
}
</script>
<template>
<div class="admin-core-workspace">
<div v-if="error" class="form-error">{{ error }}</div>
<section v-if="issued" class="issued-credential"><div><span>ONE-TIME CREDENTIAL</span><h2>{{ issued.title }}</h2><p>请通过线下安全渠道交付;关闭后不再展示。</p></div><dl><div><dt>登录账号</dt><dd>{{ issued.account }}</dd></div><div><dt>临时密码</dt><dd>{{ issued.password }}</dd></div></dl><button class="app-button" @click="issued = null">我已保存</button></section>
<template v-if="page === 'dashboard'"><section class="scope-banner-vue"><span>{{ sessionStore.state.user?.adminLevel }}</span><div><strong>{{ data.scopeLabel }}</strong><small>以下指标已按当前管理员数据范围过滤</small></div></section><section class="record-metrics"><article v-for="(value, key) in data.metrics" :key="key"><span>{{ key }}</span><strong>{{ value }}</strong></article></section><section class="record-panel audit-ledger"><header><div><h2>最近操作</h2><p>系统审计日志</p></div></header><div v-for="log in data.logs" :key="log.id" class="dashboard-row"><b>{{ String(log.actorName || '系').slice(0, 1) }}</b><span><strong>{{ log.actorName }} · {{ log.action }}</strong><small>{{ log.detail }}</small></span><time>{{ log.createdAt }}</time></div></section></template>
<template v-else-if="page === 'schools'"><form class="business-form admin-create-strip" @submit.prevent="createSchool"><header><div><p>ORGANIZATION</p><h2>新增学校档案</h2></div></header><div class="form-grid"><label><span>学校名称</span><input v-model="schoolForm.name" required></label><label><span>学校代码</span><input v-model="schoolForm.code" required></label><label><span>地址</span><input v-model="schoolForm.address"></label></div><div class="check-row"><label><input v-model="schoolForm.isSourceSchool" type="checkbox"> 生源学校</label><label><input v-model="schoolForm.isAdmissionSchool" type="checkbox"> 招生学校</label><label><input v-model="schoolForm.active" type="checkbox"> 创建后启用</label></div><button class="app-button app-button--primary" :disabled="busy">创建学校</button></form><section class="record-panel"><header><div><h2>学校名录</h2><p>共 {{ data.schools?.length || 0 }} 所</p></div></header><div class="table-scroll"><table><thead><tr><th>学校 / 代码</th><th>类型</th><th>地址</th><th>班级</th><th>考生</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.schools" :key="item.id"><td><strong>{{ item.name }}</strong><small>{{ item.code }}</small></td><td>{{ [item.isSourceSchool && '生源校', item.isAdmissionSchool && '招生校'].filter(Boolean).join(' / ') }}</td><td>{{ item.address || '未填写' }}</td><td>{{ item.classCount }}</td><td>{{ item.candidateCount }}</td><td><StatusBadge :value="item.active ? 'active' : 'disabled'" /></td><td><button class="table-action" @click="toggleSchool(item)">{{ item.active ? '停用' : '启用' }}</button></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'organization'"><div class="excel-action-bar"><a href="/api/admin/excel/classes?template=1">下载班级模板</a><label>导入班级 Excel<input type="file" accept=".xlsx" hidden @change="importExcel('classes', $event)"></label><a href="/api/admin/excel/classes">导出班级台账</a><a href="/api/admin/excel/class_admins">导出管理员台账</a></div><form class="business-form admin-create-strip" @submit.prevent="createClass"><h2>新增本校班级</h2><div class="form-grid"><label><span>年级</span><input v-model="classForm.grade" required placeholder="例如:九年级"></label><label><span>班级名称</span><input v-model="classForm.name" required placeholder="例如:1 班"></label></div><button class="app-button app-button--primary">创建班级</button></form><section class="organization-card-grid"><article v-for="item in data.classes" :key="item.id" class="record-panel org-card"><header><div><span>{{ item.grade }}</span><h2>{{ item.name }}</h2></div><StatusBadge :value="item.active ? 'active' : 'disabled'" /></header><strong>{{ item.candidateCount }} 名考生</strong><div v-for="admin in item.admins" :key="admin.id" class="dashboard-row"><b>{{ admin.displayName?.slice(0,1) }}</b><span><strong>{{ admin.displayName }}</strong><small>{{ admin.username }}</small></span><StatusBadge :value="admin.active ? 'active' : 'disabled'" /></div><footer><button class="table-action" @click="toggleClass(item)">{{ item.active ? '停用班级' : '启用班级' }}</button></footer></article></section><form class="business-form admin-create-strip" @submit.prevent="createAdmin"><h2>新增班级管理员</h2><div class="form-grid"><label><span>姓名</span><input v-model="adminForm.displayName" required></label><label><span>账号</span><input v-model="adminForm.username" required></label><label><span>初始密码</span><input v-model="adminForm.password" type="password" minlength="8" required></label><label><span>绑定班级</span><select v-model="adminForm.classId" required><option value="">请选择</option><option v-for="item in data.classes" :key="item.id" :value="item.id">{{ item.grade }} · {{ item.name }}</option></select></label></div><button class="app-button app-button--primary">创建班级管理员</button></form></template>
<template v-else-if="page === 'admins'"><form class="business-form admin-create-strip" @submit.prevent="createAdmin"><header><div><p>ACCOUNT AUTHORITY</p><h2>创建管理员</h2></div><button type="button" class="app-button" @click="setSelfRegistration(!data.selfRegistrationEnabled)">{{ data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册' }}</button></header><div class="form-grid"><label><span>姓名</span><input v-model="adminForm.displayName" required></label><label><span>登录账号</span><input v-model="adminForm.username" required></label><label><span>初始密码</span><input v-model="adminForm.password" type="password" minlength="8" required></label><label><span>管理员层级</span><select v-model="adminForm.adminLevel"><option value="super">超级管理员</option><option value="school">校级管理员</option><option value="class">班级管理员</option></select></label><label v-if="adminForm.adminLevel !== 'super'"><span>绑定学校</span><select v-model="adminForm.schoolId" required><option value="">请选择</option><option v-for="school in data.schools" :key="school.id" :value="school.id">{{ school.name }}</option></select></label><label v-if="adminForm.adminLevel === 'class'"><span>绑定班级</span><select v-model="adminForm.classId" required><option value="">请选择</option><option v-for="item in availableClasses" :key="item.id" :value="item.id">{{ item.grade }} · {{ item.name }}</option></select></label></div><button class="app-button app-button--primary">创建管理员</button></form><section class="record-panel"><header><div><h2>管理员账户</h2><p>账户不物理删除,停用后保留历史审批记录。</p></div></header><div class="table-scroll"><table><thead><tr><th>管理员</th><th>账号</th><th>层级</th><th>范围</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.admins" :key="item.id"><td>{{ item.displayName }}</td><td>{{ item.username }}</td><td>{{ item.levelName || item.adminLevel }}</td><td>{{ item.schoolName || '全局' }} {{ item.className || '' }}</td><td><StatusBadge :value="item.active ? 'active' : 'disabled'" /></td><td><button class="table-action" :disabled="item.id === sessionStore.state.user?.id" @click="resetAdmin(item)">重置密码</button><button class="table-action" :disabled="item.id === sessionStore.state.user?.id" @click="toggleAdmin(item)">{{ item.active ? '停用' : '启用' }}</button></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'account-batches'"><div class="excel-action-bar"><a href="/api/admin/excel/account_quotas?template=1">下载配额模板</a><a href="/api/admin/excel/account_quotas">导出申领配额</a></div><form class="business-form" @submit.prevent="submitBatch"><p>SCHOOL ACCOUNT REQUEST</p><h2>按班级申领报名号</h2><div class="quota-grid-vue"><label v-for="item in data.classes" :key="item.id"><span><strong>{{ item.name }}</strong><small>{{ item.grade }}</small></span><input v-model="batchQuotas[item.id]" type="number" min="0" max="200"></label></div><button class="app-button app-button--primary">提交批量申领</button></form><section class="batch-ledger-vue"><article v-for="batch in data.batches" :key="batch.id" class="record-panel batch-card-vue"><header><div><span>{{ batch.id }}</span><h2>{{ batch.schoolName }} · {{ batch.totalCount }} 个报名号</h2></div><StatusBadge :value="batch.status" /></header><div class="chip-list"><span v-for="quota in batch.quotas" :key="quota.classId">{{ quota.className }}<small>{{ quota.count }} 人</small></span></div><div v-if="batch.status === 'pending'" class="row-decision"><input v-model="reviewNotes[batch.id]" placeholder="审批意见"><button @click="reviewBatch(batch, 'rejected')">退回</button><button @click="reviewBatch(batch, 'approved')">通过</button></div><a v-if="batch.status === 'approved'" class="app-button" :href="`/api/admin/excel/account_results?batchId=${batch.id}`">导出账号下发清单</a></article></section></template>
<template v-else-if="page === 'candidates'"><div class="excel-action-bar"><a href="/api/admin/excel/candidates?template=1">下载导入模板</a><label>导入考生 Excel<input type="file" accept=".xlsx" hidden @change="importExcel('candidates', $event)"></label><a href="/api/admin/excel/candidates">导出考生台账</a></div><form v-if="sessionStore.state.user?.adminLevel === 'school'" class="archive-console-vue" @submit.prevent="archiveCandidates"><div><p>SCHOOL ACCOUNT ARCHIVE</p><h2>按班级或年级归档账户</h2><span>只冻结登录,不删除报名、准考证、成绩和审计记录。</span></div><select v-model="archiveForm.scopeType"><option value="class">按班级</option><option value="grade">按年级</option></select><select v-model="archiveForm.scopeValue" required><option value="">请选择范围</option><option v-for="item in (archiveForm.scopeType === 'class' ? data.classes : [...new Set((data.classes || []).map(row => row.grade))].map(grade => ({ id: grade, name: grade })))" :key="item.id" :value="item.id">{{ item.grade ? `${item.grade} · ${item.name}` : item.name }}</option></select><select v-model="archiveForm.archived"><option :value="true">归档账户</option><option :value="false">恢复账户</option></select><button class="app-button app-button--primary">执行</button></form><section class="record-panel candidate-ledger"><header><div><h2>考生资料审核台账</h2><p>筛选结果 {{ filtered.length }} 人,共 {{ data.candidates?.length || 0 }} 人</p></div></header><div class="ledger-toolbar candidate-ledger__toolbar"><label><span>关键词</span><input v-model="query" placeholder="姓名、报名号、证件号"></label><label><span>学校</span><select v-model="candidateSchool"><option value="">全部学校</option><option v-for="school in candidateSchools" :key="school" :value="school">{{ school }}</option></select></label><label><span>审核状态</span><select v-model="candidateStatus"><option value="">全部状态</option><option v-for="status in candidateStatuses" :key="status" :value="status">{{ candidateStatusLabel(status) }}</option></select></label><label><span>每页显示</span><select v-model="candidatePageSize"><option :value="20">20 条</option><option :value="50">50 条</option><option :value="100">100 条</option></select></label></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>证件</th><th>学校班级</th><th>账户</th><th>状态</th><th>审核</th></tr></thead><tbody><tr v-for="item in pagedCandidates" :key="item.id"><td><strong>{{ item.name }}</strong><small>{{ item.candidateNumber }}</small></td><td>{{ item.idNumberMasked }}</td><td>{{ item.school }}<small>{{ item.grade }}</small></td><td>{{ item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : '正常' }}</td><td><StatusBadge :value="item.status" /></td><td><div class="row-decision"><input v-model="reviewNotes[item.id]" placeholder="审核意见"><button @click="reviewCandidate(item, 'rejected')">退回</button><button @click="reviewCandidate(item, 'approved')">通过</button><button v-if="sessionStore.state.user?.adminLevel === 'super'" @click="resetCandidate(item)">重置密码</button></div></td></tr><tr v-if="!pagedCandidates.length"><td class="table-empty" colspan="6">没有符合当前条件的考生</td></tr></tbody></table></div><footer class="ledger-pagination"><span>第 {{ candidatePage }} / {{ candidatePageCount }} 页</span><div><button :disabled="candidatePage <= 1" @click="candidatePage--">上一页</button><button :disabled="candidatePage >= candidatePageCount" @click="candidatePage++">下一页</button></div></footer></section></template>
<template v-else-if="page === 'indicator-qualifications'"><section v-for="group in data.exams" :key="group.exam?.id || group.id" class="record-panel"><header><div><h2>{{ group.exam?.name || group.name }}</h2><p>已确认 {{ group.qualificationStatus?.confirmed || 0 }} / {{ group.qualificationStatus?.total || 0 }} 人;全部确认后系统自动公示。</p></div><div><button class="table-action" @click="bulkQualification(group, false)">全部无资格</button><button class="table-action" @click="bulkQualification(group, true)">全部有资格</button></div></header><div class="table-scroll"><table><thead><tr><th>考生</th><th>报名号</th><th>特长</th><th>确认状态</th><th>当前资格</th><th>确认</th></tr></thead><tbody><tr v-for="item in group.qualificationStatus?.rows || []" :key="item.userId"><td>{{ item.name }}</td><td>{{ item.registrationNumber }}</td><td>{{ item.specialtyLabel || '普通生' }}</td><td>{{ item.confirmed ? '已确认' : '待确认' }}</td><td><StatusBadge :value="!item.confirmed ? 'pending' : item.eligible ? 'approved' : 'rejected'" /></td><td><button class="table-action" @click="saveQualification(group, item, false)">无资格</button><button class="table-action" @click="saveQualification(group, item, true)">有资格</button></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'registrations'"><section class="record-panel"><header><div><h2>考试报名审核台账</h2><p>{{ data.registrations?.length || 0 }} 条</p></div></header><div class="table-scroll"><table><thead><tr><th>考生</th><th>考试</th><th>科目</th><th>缴费</th><th>状态</th><th>审核</th></tr></thead><tbody><tr v-for="item in data.registrations" :key="item.id"><td><strong>{{ item.candidate?.name || item.candidateName }}</strong><small>{{ item.registrationNumber }}</small></td><td>{{ item.exam?.name || item.examName }}</td><td>{{ item.subjects?.map(row => row.name).join('、') }}</td><td><StatusBadge :value="item.paymentStatus" /></td><td><StatusBadge :value="item.status" /></td><td><div class="row-decision"><input v-model="reviewNotes[item.id]" placeholder="审核意见"><button @click="reviewRegistration(item, 'rejected')">退回</button><button @click="reviewRegistration(item, 'approved')">通过</button></div></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'payments'"><div class="excel-action-bar"><a href="/api/admin/excel/payments">导出缴费名单</a></div><section class="record-panel"><header><div><h2>线下缴费台账</h2><p>{{ data.registrations?.length || data.payments?.length || 0 }} 条</p></div></header><div class="table-scroll"><table><thead><tr><th>考生</th><th>考试</th><th>应缴金额</th><th>状态</th><th>更新</th></tr></thead><tbody><tr v-for="item in (data.registrations || data.payments)" :key="item.id"><td>{{ item.candidate?.name || item.candidateName }}<small>{{ item.registrationNumber }}</small></td><td>{{ item.exam?.name || item.examName }}</td><td>{{ item.amountDue }}</td><td><StatusBadge :value="item.paymentStatus" /></td><td><button class="table-action" @click="updatePayment(item, 'unpaid')">标记待缴</button><button class="table-action" @click="updatePayment(item, 'paid')">确认已缴</button></td></tr></tbody></table></div></section></template>
<AccountSecurity v-else-if="page === 'security'" :status="data" @updated="emit('reload')" />
</div>
</template>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import PortalShell from '@/layouts/PortalShell.vue';
import PageState from '@/components/common/PageState.vue';
import AdminCoreWorkspace from './AdminCoreWorkspace.vue';
import AdminExamWorkspace from './AdminExamWorkspace.vue';
import AdminAdmissionWorkspace from './AdminAdmissionWorkspace.vue';
import AdminSystemWorkspace from './AdminSystemWorkspace.vue';
import { api } from '@/lib/api';
import { sessionStore } from '@/stores/session';
const props = defineProps({ page: { type: String, required: true } });
const loading = ref(true);
const error = ref('');
const data = ref({});
const corePages = new Set(['dashboard','schools','organization','admins','account-batches','candidates','indicator-qualifications','registrations','payments','security']);
const examPages = new Set(['exams','admit','results']);
const admissionPages = new Set(['admission-settings','admission-accounts','admission-plans','admission-reporting','admission-supervision']);
const systemPages = new Set(['notices','centers','flows','flow-design','number-rules']);
const meta = computed(() => ({
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], schools: ['学校管理', '创建和维护学校档案及公开状态。'],
organization: ['本校组织与权限', '维护本校班级和班级管理员。'], admins: ['分级管理员', '维护管理员账号和权限范围。'],
'account-batches': ['批量报名号申领', '按班级提交申领人数并跟踪审批结果。'], candidates: ['考生资料审核', '核验实名、学籍与联系信息。'],
'indicator-qualifications': ['指标分配资格确认', '由生源校逐人确认指标分配资格。'], registrations: ['考试报名审核', '审核考试、科目和报名状态。'],
payments: ['缴费名单', '查看、导出并维护线下缴费状态。'], admit: ['准考证编排', '查看或批量编排准考证。'],
exams: ['考试与科目', '创建考试并配置科目和时间。'], results: ['成绩管理中心', '录入、发布并分析考试成绩。'],
'admission-settings': ['录取设置', '设置志愿窗口和录取阶段。'], 'admission-accounts': ['招生学校账户', '创建、停用和维护招生学校账户。'],
'admission-plans': ['招生计划', '审核招生计划并查看完成率。'], 'admission-reporting': ['报到与补录', '审批报到统计和补录决定。'],
'admission-supervision': ['投档与退档监督', '监督投档记录并审批特殊退档。'], notices: ['通知发布', '维护草稿、发布通知和公开状态。'],
centers: ['考务场所档案', '管理考点、考场容量和变更申请。'], flows: ['流程中心', '处理、转交或监督审批流程。'],
'flow-design': ['流程设计', '配置各类业务审批步骤。'], 'number-rules': ['报名号规则', '设计报名号组成和流水规则。'], security: ['账户安全', '修改密码并管理二次验证。']
}[props.page] || [props.page, '管理当前业务数据。']));
function endpoint() {
if (props.page === 'admit') return 'admission-arrangements';
if (props.page === 'flows') return 'workflow-instances';
if (props.page === 'flow-design') return 'workflows';
if (props.page === 'account-batches') return 'candidate-account-batches';
if (props.page === 'organization') return 'school-organization';
if (props.page.startsWith('admission-')) return 'admissions';
return props.page;
}
async function load() {
loading.value = true; error.value = '';
try {
data.value = props.page === 'security' ? await api('/api/auth/totp') : props.page === 'results'
? { exams: sessionStore.state.publicData.exams || [], results: [], message: '请选择考试后加载成绩' }
: await api(`/api/admin/${endpoint()}`);
} catch (requestError) { error.value = requestError.message; }
finally { loading.value = false; }
}
watch(() => props.page, load);
onMounted(load);
</script>
<template>
<PortalShell role="admin" :page="page" :title="meta[0]" :description="meta[1]">
<PageState :loading="loading" :error="error" @retry="load">
<AdminCoreWorkspace v-if="corePages.has(page)" :page="page" :data="data" @reload="load" />
<AdminExamWorkspace v-else-if="examPages.has(page)" :page="page" :data="data" @reload="load" />
<AdminAdmissionWorkspace v-else-if="admissionPages.has(page)" :page="page" :data="data" @reload="load" />
<AdminSystemWorkspace v-else-if="systemPages.has(page)" :page="page" :data="data" @reload="load" />
<div v-else class="page-state page-state--empty"><strong>页面配置不存在</strong><p>请从左侧导航重新选择业务页面</p></div>
</PageState>
</PortalShell>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { api } from '@/lib/api';
import { sessionStore } from '@/stores/session';
import { uiStore } from '@/stores/ui';
import { chinaRegions } from '@/data/china-regions';
const props = defineProps({ page: { type: String, required: true }, data: { type: Object, default: () => ({}) } });
const emit = defineEmits(['reload']);
const busy = ref(false);
const error = ref('');
const search = ref('');
const notes = reactive({});
const notice = reactive({ id: '', category: '报名通知', status: 'draft', title: '', summary: '', content: '', pinned: false });
const center = reactive({ id: '', schoolId: '', code: '', name: '', provinceCode: '', cityCode: '', districtCode: '', address: '', managerName: '', managerPhone: '', contact: '', emergencyPhone: '', gateOpenTime: '', status: 'active', transport: '', notes: '', rooms: [{ code: '', name: '', building: '', floor: '', capacity: 30, seatPlan: '', roomType: 'standard', status: 'active', notes: '' }] });
const rule = reactive({ id: '', name: '固定报名号规则', separator: '-', year: true, school_code: true, gender: false, literal: false, literalValue: '', yearWidth: 4, sequenceWidth: 4 });
const centerCities = computed(() => chinaRegions.find(item => item.code === center.provinceCode)?.cities || []);
const centerDistricts = computed(() => centerCities.value.find(item => item.code === center.cityCode)?.districts || []);
async function act(action, success) {
busy.value = true; error.value = '';
try { await action(); if (success) uiStore.notify(success); emit('reload'); }
catch (requestError) { error.value = requestError.message; }
finally { busy.value = false; }
}
function editNotice(item) { Object.assign(notice, { id: item.id, category: item.category, status: item.status, title: item.title, summary: item.summary || '', content: item.content || '', pinned: Boolean(item.pinned) }); window.scrollTo({ top: 0, behavior: 'smooth' }); }
function saveNotice() { act(() => api(notice.id ? `/api/admin/notices/${notice.id}` : '/api/admin/notices', { method: notice.id ? 'PATCH' : 'POST', body: notice }), notice.status === 'published' ? '通知已发布' : '通知草稿已保存'); }
function toggleNotice(item) { act(() => api(`/api/admin/notices/${item.id}`, { method: 'PATCH', body: { status: item.status === 'published' ? 'draft' : 'published' } }), item.status === 'published' ? '通知已撤回' : '通知已发布'); }
function togglePublication(item) { act(() => api(`/api/admin/publications/${item.sourceType}/${item.id}`, { method: 'PATCH', body: { publicVisible: !item.publicVisible } }), item.publicVisible ? '系统公示已隐藏' : '系统公示已公开'); }
function addRoom() { center.rooms.push({ code: '', name: '', building: '', floor: '', capacity: 30, seatPlan: '', roomType: 'standard', status: 'active', notes: '' }); }
function resetCenter() { Object.assign(center, { id: '', schoolId: '', code: '', name: '', provinceCode: '', cityCode: '', districtCode: '', address: '', managerName: '', managerPhone: '', contact: '', emergencyPhone: '', gateOpenTime: '', status: 'active', transport: '', notes: '', rooms: [{ code: '', name: '', building: '', floor: '', capacity: 30, seatPlan: '', roomType: 'standard', status: 'active', notes: '' }] }); }
function editCenter(item) { Object.assign(center, { ...item, id: item.id, rooms: (item.rooms || []).map(room => ({ ...room })) }); window.scrollTo({ top: 0, behavior: 'smooth' }); }
function saveCenter() { const editing = Boolean(center.id); act(() => api(editing ? `/api/admin/centers/${center.id}` : '/api/admin/centers', { method: editing ? 'PATCH' : 'POST', body: { ...center, rooms: center.rooms.map(room => ({ ...room, capacity: Number(room.capacity) })) } }), editing ? '考点变更已提交审批' : '新考点档案已提交审批'); }
function reviewCenterRequest(item, status) { act(() => api(`/api/admin/center-change-requests/${item.id}`, { method: 'PATCH', body: { status, reviewNote: notes[item.id] || '' } }), status === 'approved' ? '考点变更已通过' : '考点变更已退回'); }
function flowPath(item) { return item.businessType === 'profile_change' ? `/api/admin/candidates/${item.businessId}` : item.businessType === 'registration_review' ? `/api/admin/registrations/${item.businessId}` : item.businessType === 'center_change' ? `/api/admin/center-change-requests/${item.businessId}` : item.businessType === 'candidate_account_batch' ? `/api/admin/candidate-account-batches/${item.businessId}` : `/api/admin/score-appeals/${item.businessId}`; }
function processFlow(item, status) { const body = { status, reviewNote: notes[item.id] || '' }; if (item.businessType === 'score_appeal' && status === 'approved') body.reviewedScore = Number(window.prompt('请输入复议后的成绩', item.appealResult?.score ?? '') || item.appealResult?.score); act(() => api(flowPath(item), { method: 'PATCH', body }), status === 'approved' ? '流程已通过当前步骤' : '流程已退回'); }
function transferFlow(item) { const assigneeId = window.prompt('请输入目标管理员 ID', '') || ''; if (assigneeId) act(() => api(`/api/admin/workflow-instances/${item.id}/transfer`, { method: 'PATCH', body: { assigneeId, note: notes[item.id] || '' } }), '流程已转交'); }
function saveWorkflow(workflow) { act(() => api(`/api/admin/workflows/${workflow.businessType}`, { method: 'PUT', body: { name: workflow.name, steps: workflow.steps.map(step => ({ name: step.name, adminLevel: step.adminLevel })) } }), '审批流程已保存'); }
function addWorkflowStep(workflow) { workflow.steps.push({ name: '新增审批步骤', adminLevel: 'school', position: workflow.steps.length + 1 }); }
function saveRule() {
const definitions = [{ type: 'year', include: rule.year, width: Number(rule.yearWidth) }, { type: 'school_code', include: rule.school_code }, { type: 'gender', include: rule.gender }, { type: 'literal', include: rule.literal, value: rule.literalValue }, { type: 'sequence', include: true, width: Number(rule.sequenceWidth) }];
const segments = definitions.filter(item => item.include).map((item, index) => ({ type: item.type, position: index + 1, value: item.value || '', width: item.width || 0 }));
act(() => api('/api/admin/number-rules', { method: 'POST', body: { id: rule.id || undefined, name: rule.name, separator: rule.separator, segments } }), '报名号规则已启用');
}
if (props.page === 'number-rules' && props.data.activeRule) {
const active = props.data.activeRule; rule.id = active.id; rule.name = active.name; rule.separator = active.separator;
for (const segment of active.segments || []) { rule[segment.type] = true; if (segment.type === 'literal') rule.literalValue = segment.value; if (segment.type === 'year') rule.yearWidth = segment.width; if (segment.type === 'sequence') rule.sequenceWidth = segment.width; }
}
</script>
<template>
<div class="admin-system-workspace">
<div v-if="error" class="form-error">{{ error }}</div>
<section v-if="page === 'centers' && data.centers?.length" class="record-panel center-edit-picker">
<header><div><h2>维护已有考点</h2><p>选择考点后下面的档案表单会切换为变更申请</p></div><button v-if="center.id" class="app-button" type="button" @click="resetCenter">取消编辑</button></header>
<div class="chip-list"><button v-for="item in data.centers" :key="item.id" type="button" :class="{ active: center.id === item.id }" @click="editCenter(item)">{{ item.code }} · {{ item.name }}</button></div>
<div v-if="center.id" class="form-callout"><strong>正在提交{{ center.name }}的变更</strong><p>审批通过前当前正式考点档案不会变化</p></div>
</section>
<template v-if="page === 'notices'"><form class="business-form notice-editor-vue" @submit.prevent="saveNotice"><header><div><p>PUBLIC INFORMATION</p><h2>{{ notice.id ? '编辑通知' : '新建通知公告' }}</h2></div><button v-if="notice.id" type="button" class="app-button" @click="Object.assign(notice,{id:'',category:'报名通知',status:'draft',title:'',summary:'',content:'',pinned:false})">新建另一条</button></header><div class="form-grid"><label><span>分类</span><select v-model="notice.category"><option v-for="item in ['报名通知','考试须知','考点公告','成绩通知','系统公告']" :key="item">{{ item }}</option></select></label><label><span>发布方式</span><select v-model="notice.status"><option value="draft">保存草稿</option><option value="published">立即发布</option></select></label></div><label><span>通知标题</span><input v-model="notice.title" required></label><label><span>首页摘要</span><input v-model="notice.summary"></label><label><span>通知正文(支持安全 HTML</span><textarea v-model="notice.content" rows="12" required></textarea></label><label class="check-inline"><input v-model="notice.pinned" type="checkbox"> 在公开首页置顶</label><button class="app-button app-button--primary">保存通知</button></form><section class="record-panel"><header><div><h2>人工通知</h2><p>{{ data.notices?.length || 0 }} 条</p></div></header><div class="table-scroll"><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>置顶</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.notices" :key="item.id"><td>{{ item.title }}<small>{{ item.summary }}</small></td><td>{{ item.category }}</td><td><StatusBadge :value="item.status" /></td><td>{{ item.pinned ? '是' : '否' }}</td><td><button class="table-action" @click="editNotice(item)">编辑</button><button class="table-action" @click="toggleNotice(item)">{{ item.status === 'published' ? '撤回' : '发布' }}</button></td></tr></tbody></table></div></section><section class="record-panel"><header><div><h2>系统自动公示</h2><p>业务事实不可编辑,只控制公开可见性。</p></div></header><div class="table-scroll"><table><thead><tr><th>标题</th><th>类型</th><th>状态</th><th>公开</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.publications" :key="item.id"><td>{{ item.title }}</td><td>{{ item.sourceType }}</td><td><StatusBadge :value="item.status" /></td><td>{{ item.publicVisible ? '公开' : '隐藏' }}</td><td><button class="table-action" @click="togglePublication(item)">{{ item.publicVisible ? '隐藏' : '公开' }}</button></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'centers'"><div class="excel-action-bar"><a href="/api/admin/excel/centers?template=1">下载考点模板</a><a href="/api/admin/excel/centers">导出考点档案</a></div><form class="business-form center-editor-vue" @submit.prevent="saveCenter"><p>CONTROLLED DOSSIER</p><h2>提交新考点档案</h2><span>提交后进入考点考场变更审批,通过前不会改动正式档案。</span><label v-if="sessionStore.state.user?.adminLevel === 'super'"><span>所属学校</span><select v-model="center.schoolId" required><option value="">请选择</option><option v-for="school in data.schools" :key="school.id" :value="school.id">{{ school.name }}</option></select></label><div class="form-grid"><label><span>考点代码</span><input v-model="center.code" required></label><label><span>考点名称</span><input v-model="center.name" required></label><label><span>省份</span><select v-model="center.provinceCode" required @change="center.cityCode = ''; center.districtCode = ''"><option value="">请选择</option><option v-for="item in chinaRegions" :key="item.code" :value="item.code">{{ item.name }}</option></select></label><label><span>城市</span><select v-model="center.cityCode" required :disabled="!center.provinceCode" @change="center.districtCode = ''"><option value="">请选择</option><option v-for="item in centerCities" :key="item.code" :value="item.code">{{ item.name }}</option></select></label><label><span>区县</span><select v-model="center.districtCode" required :disabled="!center.cityCode"><option value="">请选择</option><option v-for="item in centerDistricts" :key="item.code" :value="item.code">{{ item.name }}</option></select></label><label><span>详细地址</span><input v-model="center.address" required></label><label><span>负责人</span><input v-model="center.managerName"></label><label><span>负责人手机</span><input v-model="center.managerPhone"></label><label><span>值班电话</span><input v-model="center.contact"></label><label><span>应急电话</span><input v-model="center.emergencyPhone"></label><label><span>开放时间</span><input v-model="center.gateOpenTime" type="time"></label></div><label><span>交通与入场提示</span><textarea v-model="center.transport"></textarea></label><section class="room-editor-list"><header><strong>考场明细</strong><button type="button" @click="addRoom"> 添加考场</button></header><article v-for="(room,index) in center.rooms" :key="index"><div class="form-grid"><label><span>场地代码</span><input v-model="room.code" required></label><label><span>考场名称</span><input v-model="room.name" required></label><label><span>楼栋</span><input v-model="room.building" required></label><label><span>楼层</span><input v-model="room.floor"></label><label><span>容量</span><input v-model="room.capacity" type="number" min="1" required></label><label><span>考场类型</span><select v-model="room.roomType"><option value="standard">标准考场</option><option value="computer">机考考场</option><option value="accessible">无障碍考场</option><option value="spare">备用考场</option></select></label></div><button type="button" @click="center.rooms.splice(index,1)">移除</button></article></section><button class="app-button app-button--primary">提交审批</button></form><section class="record-panel"><header><div><h2>正式考点与考场</h2><p>{{ data.centers?.length || 0 }} 个考点</p></div></header><div class="table-scroll"><table><thead><tr><th>考点</th><th>学校</th><th>地址</th><th>考场 / 席位</th><th>负责人</th><th>状态</th></tr></thead><tbody><tr v-for="item in data.centers" :key="item.id"><td>{{ item.name }}<small>{{ item.code }}</small></td><td>{{ item.schoolName }}</td><td>{{ item.address }}</td><td>{{ item.rooms?.length }} 个 / {{ item.totalCapacity }} 席</td><td>{{ item.managerName }}<small>{{ item.managerPhone }}</small></td><td><StatusBadge :value="item.status" /></td></tr></tbody></table></div></section><section class="record-panel"><header><div><h2>考点变更审批台账</h2><p>{{ data.changeRequests?.length || 0 }} 条</p></div></header><div class="table-scroll"><table><thead><tr><th>类型</th><th>考点 / 学校</th><th>考场</th><th>状态</th><th>审批</th></tr></thead><tbody><tr v-for="item in data.changeRequests" :key="item.id"><td>{{ item.requestType === 'create' ? '新增' : '修改' }}</td><td>{{ item.name }}<small>{{ item.schoolName }}</small></td><td>{{ item.rooms?.length }} 个</td><td><StatusBadge :value="item.status" /></td><td><div v-if="item.status === 'pending'" class="row-decision"><input v-model="notes[item.id]" placeholder="审批意见"><button @click="reviewCenterRequest(item,'rejected')">退回</button><button @click="reviewCenterRequest(item,'approved')">通过</button></div></td></tr></tbody></table></div></section></template>
<template v-else-if="page === 'flows'"><label class="record-search"><span>搜索流程</span><input v-model="search" placeholder="考生、学校、考试、责任人或流程"></label><section class="workflow-grid-vue"><article v-for="item in data.instances?.filter(row => !search || JSON.stringify(row).toLowerCase().includes(search.toLowerCase()))" :key="item.id" class="record-panel flow-card-vue"><header><div><span>{{ item.businessType }}</span><h2>{{ item.candidateName || item.centerName || item.schoolName || item.id }}</h2><p>{{ item.examName }} {{ item.className }}</p></div><StatusBadge :value="item.status" /></header><div class="workflow-track-vue"><span v-for="step in item.steps" :key="step.position" :class="{ done: step.position < item.currentStep, current: step.position === item.currentStep && item.status === 'pending' }"><i>{{ step.position < item.currentStep ? '✓' : step.position }}</i><b>{{ step.name }}</b><small>{{ step.adminLevel }}</small></span></div><footer><div><strong>当前责任人:{{ item.assignee?.displayName || '流程已结束' }}</strong><input v-model="notes[item.id]" placeholder="处理意见"></div><button v-if="item.status === 'pending'" class="table-action" @click="transferFlow(item)">转交</button><button v-if="item.status === 'pending'" class="table-action" @click="processFlow(item,'rejected')">退回</button><button v-if="item.status === 'pending'" class="table-action" @click="processFlow(item,'approved')">通过</button></footer></article></section></template>
<template v-else-if="page === 'flow-design'"><section class="workflow-design-grid-vue"><form v-for="workflow in data.workflows" :key="workflow.businessType" class="business-form" @submit.prevent="saveWorkflow(workflow)"><header><div><p>{{ workflow.businessType }}</p><h2>{{ workflow.name }}</h2></div><button type="button" class="app-button" @click="addWorkflowStep(workflow)">添加步骤</button></header><label><span>流程名称</span><input v-model="workflow.name" required></label><div v-for="(step,index) in workflow.steps" :key="index" class="workflow-step-row-vue"><b>{{ index + 1 }}</b><input v-model="step.name" required><select v-model="step.adminLevel"><option value="class">班级管理员</option><option value="school">校级管理员</option><option value="super">超级管理员</option></select><button type="button" @click="workflow.steps.splice(index,1)">×</button></div><button class="app-button app-button--primary">保存流程</button></form></section></template>
<template v-else-if="page === 'number-rules'"><section class="account-number-principle-vue"><span>ONE CANDIDATE · ONE NUMBER</span><h2>超级管理员只设计号码规则</h2><p>学校按班级提交申领,最终批准后系统才创建长期考生账户。</p></section><div class="number-rule-layout-vue"><form class="business-form" @submit.prevent="saveRule"><h2>报名号组成</h2><div class="form-grid"><label><span>规则名称</span><input v-model="rule.name" required></label><label><span>分隔符</span><input v-model="rule.separator" maxlength="3"></label></div><div class="rule-segment-grid"><label><input v-model="rule.year" type="checkbox"> 年份 <input v-model="rule.yearWidth" type="number" min="2" max="6"></label><label><input v-model="rule.school_code" type="checkbox"> 学校代码</label><label><input v-model="rule.gender" type="checkbox"> 性别 M/F/X</label><label><input v-model="rule.literal" type="checkbox"> 固定值 <input v-model="rule.literalValue"></label><label><input type="checkbox" checked disabled> 流水号 <input v-model="rule.sequenceWidth" type="number" min="1" max="12"></label></div><button class="app-button app-button--primary">保存并启用规则</button></form><aside><span>审批后账户样例</span><strong>{{ data.preview || '2026-HZ01-X-0001' }}</strong><p>报名号创建后保持不变</p></aside></div></template>
</div>
</template>
@@ -0,0 +1,181 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import PortalShell from '@/layouts/PortalShell.vue';
import PageState from '@/components/common/PageState.vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/format';
import { uiStore } from '@/stores/ui';
import { specialtyCatalog, specialtyTypesFor } from '@/data/specialties';
const props = defineProps({ page: { type: String, required: true } });
const router = useRouter();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const data = ref({});
const search = ref('');
const statusFilter = ref('all');
const examFilter = ref('');
const selectedPlacements = ref([]);
const placementDrafts = reactive({});
const reportingDrafts = reactive({});
const scan = reactive({ examId: '', code: '', preview: null, status: 'reported', note: '' });
const planForm = reactive({ examId: '', note: '', categories: [] });
const templateForm = reactive({ examId: '', eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书', body: '', footer: '', primaryColor: '#8d2028', accentColor: '#c9a45b' });
const meta = computed(() => ({
dashboard: ['招生工作台', '查看本校计划完成率、报到进度和待办事项。'],
plans: ['本校招生计划', '提交普通生、特长生计划及指标分配。'],
placements: ['投档考生审核', '核对投档考生资料和当次成绩。'],
reporting: ['考生报到', '暂存报到状态,支持 Excel 与通知书扫码核验。'],
'notice-template': ['录取通知书模板', '设计本校录取通知书标题、正文与配色。']
}[props.page]));
const placements = computed(() => (data.value.placements || []).filter(item => {
const text = `${item.candidate?.name || ''} ${item.candidate?.registrationNumber || ''} ${item.examName || ''} ${item.payload?.categoryName || ''} ${item.candidate?.specialtyLabel || ''}`.toLowerCase();
return (!search.value || text.includes(search.value.toLowerCase())) && (statusFilter.value === 'all' || item.status === statusFilter.value) && (!examFilter.value || item.examId === examFilter.value);
}));
const placementExams = computed(() => [...new Map((data.value.placements || []).map(item => [item.examId, item.examName])).entries()]);
const selectedPending = computed(() => placements.value.filter(item => item.status === 'school_review' && selectedPlacements.value.includes(item.id)));
const previewBody = computed(() => templateForm.body.replaceAll('{{考生姓名}}', '张同学').replaceAll('{{考试名称}}', '示例考试').replaceAll('{{录取学校}}', data.value.school?.name || '本校').replaceAll('{{录取类别}}', '普通生'));
function emptyCategory() { return { name: '普通生', quota: '', kind: 'general', specialtyCategory: '', specialtyType: '', indicatorAllocations: [] }; }
function addCategory() { planForm.categories.push(emptyCategory()); }
function addAllocation(category) { category.indicatorAllocations.push({ sourceSchoolId: '', quota: '' }); }
function resetSpecialty(category) { category.specialtyCategory = ''; category.specialtyType = ''; }
async function load() {
loading.value = true; error.value = ''; search.value = ''; statusFilter.value = 'all'; examFilter.value = ''; selectedPlacements.value = [];
try {
data.value = await api(`/api/admission/${props.page === 'dashboard' ? 'context' : props.page}`);
hydrate();
} catch (requestError) { error.value = requestError.message; }
finally { loading.value = false; }
}
function hydrate() {
if (props.page === 'plans') {
planForm.examId = data.value.exams?.[0]?.id || '';
planForm.note = '';
planForm.categories = [emptyCategory()];
}
if (props.page === 'placements') for (const item of data.value.placements || []) placementDrafts[item.id] = { decision: 'accept', note: '' };
if (props.page === 'reporting') for (const batch of data.value.batches || []) for (const row of batch.rows || []) reportingDrafts[row.placementId] = { status: row.status, note: row.note || '', selected: false };
if (props.page === 'notice-template') Object.assign(templateForm, {
examId: data.value.exams?.[0]?.id || '', eyebrow: data.value.template?.eyebrow || 'ADMISSION NOTICE',
title: data.value.template?.title || '录 取 通 知 书', body: data.value.template?.body || '', footer: data.value.template?.footer || '',
primaryColor: data.value.template?.primaryColor || '#8d2028', accentColor: data.value.template?.accentColor || '#c9a45b'
});
}
async function act(action, success) {
saving.value = true; error.value = '';
try { await action(); if (success) uiStore.notify(success); await load(); }
catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; }
}
function submitPlan() {
const categories = planForm.categories.map((category, index) => ({
code: `category_${index + 1}`, name: String(category.name).trim(), quota: Number(category.quota || 0), isSpecialty: category.kind === 'specialty',
specialtyCategory: category.kind === 'specialty' ? category.specialtyCategory : '', specialtyType: category.kind === 'specialty' ? category.specialtyType : '',
indicatorAllocations: category.indicatorAllocations.map(item => ({ sourceSchoolId: item.sourceSchoolId, quota: Number(item.quota || 0) })).filter(item => item.sourceSchoolId && item.quota > 0)
})).filter(category => category.name && category.quota > 0);
if (!categories.length) { error.value = '请至少添加一个有效招生类别'; return; }
if (categories.some(category => category.isSpecialty && (!category.specialtyCategory || !category.specialtyType))) { error.value = '特长生类别必须填写特长大类和小类'; return; }
act(() => api('/api/admission/plans', { method: 'POST', body: { examId: planForm.examId, note: planForm.note, categories } }), '招生计划已提交审核');
}
function reviewPlacement(item) {
const draft = placementDrafts[item.id];
if (draft.decision === 'withdraw' && draft.note.trim().length < 8) { error.value = '申请退档须填写至少 8 个字的特殊理由'; return; }
act(() => api(`/api/admission/placements/${item.id}`, { method: 'PATCH', body: draft }), draft.decision === 'accept' ? '已接收投档考生' : '退档申请已提交');
}
function bulkPlacement(decision) {
if (!selectedPending.value.length) { error.value = '请先选择待审核考生'; return; }
let note = '';
if (decision === 'withdraw') {
note = window.prompt(`为所选 ${selectedPending.value.length} 名考生填写统一退档理由(至少 8 个字)`, '') || '';
if (!note) return;
if (note.trim().length < 8) { error.value = '退档理由至少需要 8 个字'; return; }
} else if (!window.confirm(`确认接收所选 ${selectedPending.value.length} 名投档考生吗?`)) return;
act(() => api('/api/admission/placements/bulk', { method: 'POST', body: { ids: selectedPending.value.map(item => item.id), decision, note } }), decision === 'accept' ? '批量接收完成' : '批量退档申请已提交');
}
function toggleFilteredPending(checked) {
const ids = placements.value.filter(item => item.status === 'school_review').map(item => item.id);
selectedPlacements.value = checked ? [...new Set([...selectedPlacements.value, ...ids])] : selectedPlacements.value.filter(id => !ids.includes(id));
}
function saveReporting(batch) {
const rows = batch.rows.map(row => ({ placementId: row.placementId, status: reportingDrafts[row.placementId].status, note: reportingDrafts[row.placementId].note }));
act(() => api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: batch.exam.id, rows } }), '报到状态已暂存');
}
function applyReportingBulk(batch, status) {
for (const row of batch.rows) if (reportingDrafts[row.placementId]?.selected) reportingDrafts[row.placementId].status = status;
}
function submitReporting(batch) {
if (!window.confirm(`确认正式提交“${batch.exam.name}”全部报到情况吗?提交后将不能继续编辑。`)) return;
act(() => api('/api/admission/reporting/submit', { method: 'POST', body: { examId: batch.exam.id } }), '报到情况已正式提交');
}
function submitDecision(batch, event) {
const form = new FormData(event.currentTarget);
act(() => api('/api/admission/reporting/decision', { method: 'POST', body: { examId: batch.exam.id, supplement: form.get('supplement') === 'true', decisionNote: form.get('decisionNote') } }), '学校补录决定已提交');
}
async function importReporting(batch, event) {
const file = event.target.files?.[0]; if (!file) return;
saving.value = true; error.value = '';
try {
const result = await api(`/api/admission/reporting/import?examId=${encodeURIComponent(batch.exam.id)}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() });
uiStore.notify('Excel 已导入暂存', `读取 ${result.count || 0} 行,更新 ${result.changedCount || 0}`); await load();
} catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; event.target.value = ''; }
}
function verifyScan(batch) {
scan.examId = batch.exam.id;
act(async () => { scan.preview = await api('/api/admission/reporting/scan-preview', { method: 'POST', body: { code: scan.code, examId: batch.exam.id } }); }, '通知书核验通过');
}
async function saveScan() {
saving.value = true; error.value = '';
try {
await api('/api/admission/reporting/scan', { method: 'POST', body: { examId: scan.examId, code: scan.code, status: scan.status, note: scan.note } });
Object.assign(scan, { examId: '', code: '', preview: null, status: 'reported', note: '' }); uiStore.notify('扫码结果已暂存'); await load();
} catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; }
}
function saveTemplate() { act(() => api('/api/admission/notice-template', { method: 'PUT', body: templateForm }), '录取通知书模板已保存'); }
watch(() => props.page, load);
onMounted(load);
</script>
<template>
<PortalShell role="admission_school" :page="page" :title="meta[0]" :description="meta[1]">
<PageState :loading="loading" :error="error" @retry="load">
<template v-if="page === 'dashboard'">
<section class="admission-command-banner"><div><span>ADMISSION OFFICE</span><h2>{{ data.school?.name }}</h2><p>学校只接收超级管理员正式投档的数据不可查看考生完整志愿表</p></div></section>
<section class="admission-progress-grid"><article v-for="plan in data.plans" :key="plan.examId"><header><span>{{ plan.examName }}</span><strong>{{ plan.progress?.admissionRate || 0 }}%</strong></header><div><i :style="{ width: `${Math.min(100, plan.progress?.admissionRate || 0)}%` }"></i></div><p>计划 {{ plan.progress?.totalQuota || 0 }} · 正式录取 {{ plan.progress?.finalCount || 0 }} · 已报到 {{ plan.progress?.reportedCount || 0 }} </p><small>实际报到完成率 {{ plan.progress?.reportingRate || 0 }}%</small></article></section>
<div class="admission-dashboard-grid"><section class="record-panel"><header><div><h2>本校工作入口</h2><p>{{ data.exams?.length || 0 }} 场考试已启用招生</p></div></header><button class="dashboard-row" @click="router.push('/admission/plans')"><b></b><span><strong>上传招生计划</strong><small>类别特长资格与指标分配</small></span><i></i></button><button class="dashboard-row" @click="router.push('/admission/placements')"><b></b><span><strong>审核投档考生</strong><small>接收或申请特殊退档</small></span><i></i></button><button class="dashboard-row" @click="router.push('/admission/reporting')"><b></b><span><strong>登记考生报到</strong><small>台账Excel 与通知书核验</small></span><i></i></button></section><section class="record-panel"><header><div><h2>系统通知</h2><p>{{ data.notifications?.length || 0 }} </p></div></header><button v-for="notice in data.notifications" :key="notice.id" class="dashboard-row" @click="router.push(`/announcements/${notice.id}`)"><time>{{ formatDate(notice.publishAt) }}</time><span><strong>{{ notice.title }}</strong></span><i></i></button></section></div>
</template>
<template v-else-if="page === 'plans'">
<form class="business-form admission-plan-form" @submit.prevent="submitPlan"><header><div><p>PLAN SUBMISSION</p><h2>提交本校招生计划</h2><span>提交后由超级管理员审核各类别指标合计不得超过该类别计划人数</span></div></header><label><span>招生考试</span><select v-model="planForm.examId" required><option v-for="exam in data.exams" :key="exam.id" :value="exam.id">{{ exam.code }} · {{ exam.name }}</option></select></label><div class="plan-category-list"><article v-for="(category, categoryIndex) in planForm.categories" :key="categoryIndex" class="plan-category-card"><header><strong>招生类别 {{ categoryIndex + 1 }}</strong><button type="button" @click="planForm.categories.splice(categoryIndex, 1)">移除</button></header><div class="form-grid"><label><span>类别名称</span><input v-model="category.name" required placeholder="例如:普通生"></label><label><span>计划人数</span><input v-model="category.quota" type="number" min="1" required></label><label><span>类别性质</span><select v-model="category.kind" @change="resetSpecialty(category)"><option value="general">普通 / 政策类</option><option value="specialty">特长生</option></select></label><label v-if="category.kind === 'specialty'"><span>特长大类</span><select v-model="category.specialtyCategory" required @change="category.specialtyType = ''"><option value="">请选择</option><option v-for="item in specialtyCatalog" :key="item.code" :value="item.code">{{ item.name }}</option></select></label><label v-if="category.kind === 'specialty'"><span>特长项目</span><select v-model="category.specialtyType" required :disabled="!category.specialtyCategory"><option value="">请选择</option><option v-for="item in specialtyTypesFor(category.specialtyCategory)" :key="item[0]" :value="item[0]">{{ item[1] }}</option></select></label></div><section><header><div><strong>生源校指标</strong><small>仅填写需要定向分配的学校</small></div><button type="button" @click="addAllocation(category)">添加指标</button></header><div v-for="(allocation, allocationIndex) in category.indicatorAllocations" :key="allocationIndex" class="allocation-row"><select v-model="allocation.sourceSchoolId"><option value="">选择生源学校</option><option v-for="school in data.sourceSchools" :key="school.id" :value="school.id">{{ school.code }} · {{ school.name }}</option></select><input v-model="allocation.quota" type="number" min="1" placeholder="名额"><button type="button" @click="category.indicatorAllocations.splice(allocationIndex, 1)">移除</button></div></section></article></div><button class="app-button" type="button" @click="addCategory"> 添加招生类别</button><label><span>计划说明</span><textarea v-model="planForm.note" rows="3" placeholder="政策依据或补充说明"></textarea></label><button class="app-button app-button--primary" :disabled="saving">提交超级管理员审核</button></form>
<section class="record-panel admission-plan-history"><header><div><h2>提交记录</h2><p>本校历次计划及实时完成率</p></div></header><div class="table-scroll"><table><thead><tr><th>考试</th><th>类别计划</th><th>完成进度</th><th>状态</th><th>审核意见</th></tr></thead><tbody><tr v-for="plan in data.plans" :key="plan.id"><td>{{ data.exams?.find(exam => exam.id === plan.examId)?.name || plan.examId }}</td><td><span v-for="category in plan.payload?.categories" :key="category.code" class="table-stack"><strong>{{ category.name }} {{ category.quota }} 人</strong><small>{{ category.isSpecialty ? '特长生' : '普通 / 政策类' }}</small></span></td><td><strong>{{ plan.progress?.admissionRate || 0 }}%</strong><small>录取 {{ plan.progress?.finalCount || 0 }} / {{ plan.progress?.totalQuota || 0 }}</small></td><td><StatusBadge :value="plan.status" /></td><td>{{ plan.payload?.reviewNote || '等待审核' }}</td></tr></tbody></table></div></section>
</template>
<template v-else-if="page === 'placements'">
<section v-if="data.completedExams?.length" class="admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息</strong><small>仅录取工作结束后开放下载。</small></div><select v-model="examFilter"><option value="">选择已完成考试</option><option v-for="exam in data.completedExams" :key="exam.id" :value="exam.id">{{ exam.name }}</option></select><a class="app-button app-button--primary" :href="`/api/admission/placements/export?examId=${encodeURIComponent(examFilter)}`" :class="{ disabled: !examFilter }">下载 Excel</a></section>
<section class="record-panel ledger-panel"><header><div><h2>本校投档审核台账</h2><p>{{ data.placements?.filter(item => item.status === 'school_review').length || 0 }} 人待审核 / {{ data.placements?.length || 0 }} </p></div></header><div class="ledger-toolbar"><input v-model="search" placeholder="搜索姓名、报名号、考试、类别或资格"><select v-model="examFilter"><option value="">全部考试</option><option v-for="([id, name]) in placementExams" :key="id" :value="id">{{ name }}</option></select><select v-model="statusFilter"><option value="all">全部状态</option><option value="school_review">待学校审核</option><option value="admitted">已接收</option><option value="withdrawal_pending">退档待审</option><option value="final">正式录取</option></select></div><div class="ledger-bulk"><label><input type="checkbox" @change="toggleFilteredPending($event.target.checked)">选择当前筛选结果中的待审核考生</label><strong>已选 {{ selectedPending.length }} 人</strong><button class="app-button" @click="bulkPlacement('withdraw')">批量申请退档</button><button class="app-button app-button--primary" @click="bulkPlacement('accept')">批量接收</button></div><div class="table-scroll"><table><thead><tr><th>选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>审核</th></tr></thead><tbody><tr v-for="item in placements" :key="item.id"><td><input v-model="selectedPlacements" type="checkbox" :value="item.id" :disabled="item.status !== 'school_review'"></td><td><strong>{{ item.candidate?.name }}</strong><small>{{ item.candidate?.registrationNumber }} · {{ item.candidate?.idNumberMasked }}</small><small>{{ item.examName }}</small></td><td>{{ item.candidate?.specialtyLabel || '普通生' }}<small>{{ item.candidate?.policyEligibility }}</small></td><td><span v-for="result in item.results" :key="result.subjectId" class="table-stack">{{ result.subjectName }} {{ result.score }}</span><strong>投档分 {{ item.payload?.totalScore }} · 特征分 {{ item.featureScore || 0 }}</strong></td><td>{{ item.payload?.categoryName }}<small>第 {{ item.payload?.preferenceOrder }} 志愿</small></td><td><StatusBadge :value="item.status" /></td><td><form v-if="item.status === 'school_review'" class="row-review-form" @submit.prevent="reviewPlacement(item)"><select v-model="placementDrafts[item.id].decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input v-model="placementDrafts[item.id].note" placeholder="退档理由至少 8 字"><button>确认</button></form><small v-else>{{ item.payload?.schoolDecisionNote || '已处理' }}</small></td></tr><tr v-if="!placements.length"><td colspan="7">没有符合条件的记录</td></tr></tbody></table></div></section>
</template>
<template v-else-if="page === 'reporting'">
<section v-for="batch in data.batches" :key="`${batch.exam.id}-${batch.round}`" class="reporting-workbench"><header><div><span>{{ batch.exam.code }} · 第 {{ batch.round }} 轮</span><h2>{{ batch.exam.name }}</h2><p>计划 {{ batch.progress?.totalQuota }} 人,正式录取 {{ batch.progress?.finalCount }} 人,已报到 {{ batch.progress?.reportedCount }} 人。</p></div><strong>{{ batch.progress?.reportingRate || 0 }}%<small>计划报到完成率</small></strong></header><div class="reporting-stat-strip"><span>正式录取 <b>{{ batch.progress?.finalCount }}</b></span><span>已报到 <b>{{ batch.progress?.reportedCount }}</b></span><span>未报到 <b>{{ batch.progress?.notReportedCount }}</b></span><span>计划缺额 <b>{{ batch.progress?.reportingGap }}</b></span><StatusBadge :value="batch.status" /></div><section v-if="['draft','rejected'].includes(batch.status)" class="reporting-tools"><div><strong>Excel 批量维护</strong><small>导入只暂存,不会直接提交。</small><span><a class="app-button" :href="`/api/admission/reporting/export?examId=${encodeURIComponent(batch.exam.id)}`">导出 Excel</a><label class="app-button">导入暂存<input type="file" accept=".xlsx" hidden @change="importReporting(batch, $event)"></label></span></div><form @submit.prevent="verifyScan(batch)"><strong>通知书二维码核验</strong><small>粘贴 AN 防伪码或二维码链接,核对身份后再暂存。</small><input v-model="scan.code" required placeholder="AN 防伪码或二维码链接"><button class="app-button">核验</button></form></section><form v-if="scan.preview && scan.examId === batch.exam.id" class="scan-preview" @submit.prevent="saveScan"><header><div><span>NOTICE VERIFIED</span><h3>核对考生报到信息</h3></div><button type="button" @click="scan.preview = null">关闭</button></header><dl><div><dt>考生</dt><dd>{{ scan.preview.row?.name }}</dd></div><div><dt>报名号</dt><dd>{{ scan.preview.row?.candidateNumber }}</dd></div><div><dt>通知书</dt><dd>{{ scan.preview.row?.noticeNumber }}</dd></div><div><dt>录取类别</dt><dd>{{ scan.preview.row?.categoryName }}</dd></div></dl><select v-model="scan.status"><option value="reported">Y · 已报到</option><option value="not_reported">N · 未报到</option><option value="pending">P · 待确认</option></select><input v-model="scan.note" placeholder="报到备注"><button class="app-button app-button--primary">确认并暂存</button></form><form v-if="['draft','rejected'].includes(batch.status)" @submit.prevent="saveReporting(batch)"><div class="ledger-bulk"><strong>本轮报到台账</strong><button type="button" class="app-button" @click="applyReportingBulk(batch, 'reported')">所选设为已报到</button><button type="button" class="app-button" @click="applyReportingBulk(batch, 'not_reported')">所选设为未报到</button></div><div class="table-scroll"><table><thead><tr><th>选择</th><th>考生</th><th>通知书 / 类别</th><th>状态</th><th>备注</th></tr></thead><tbody><tr v-for="row in batch.rows" :key="row.placementId"><td><input v-model="reportingDrafts[row.placementId].selected" type="checkbox"></td><td><strong>{{ row.name }}</strong><small>{{ row.candidateNumber }}</small></td><td><strong>{{ row.noticeNumber }}</strong><small>{{ row.categoryName }}</small></td><td><select v-model="reportingDrafts[row.placementId].status"><option value="pending">P · 待确认</option><option value="reported">Y · 已报到</option><option value="not_reported">N · 未报到</option></select></td><td><input v-model="reportingDrafts[row.placementId].note" placeholder="选填报到备注"></td></tr></tbody></table></div><footer><button class="app-button">暂存全部状态</button><button class="app-button app-button--primary" type="button" @click="submitReporting(batch)">正式提交报到情况</button></footer></form><form v-else-if="batch.status === 'submitted'" class="reporting-decision" @submit.prevent="submitDecision(batch, $event)"><div><strong>报到情况已提交</strong><p>请选择是否申请补录,学校决定将提交超级管理员审批。</p></div><select name="supplement"><option value="false">不进行补录</option><option value="true" :disabled="!batch.progress?.reportingGap">申请补录 {{ batch.progress?.reportingGap }} 人</option></select><input name="decisionNote" placeholder="补录原因或不补录说明"><button class="app-button app-button--primary">提交学校决定</button></form><div v-else class="form-callout"><strong>当前批次已锁定</strong><p>{{ batch.approvalNote || batch.decisionNote || '等待下一步处理' }}</p></div></section><div v-if="!data.batches?.length" class="page-state page-state--empty"><strong>暂无报到批次</strong><p>正式录取签发并开启报到后本页会生成台账</p></div>
</template>
<section v-else-if="page === 'notice-template'" class="notice-template-studio"><form class="business-form" @submit.prevent="saveTemplate"><p>TEMPLATE STUDIO</p><h2>模板设计</h2><span v-pre>正文支持:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</span><label><span>适用考试</span><select v-model="templateForm.examId"><option v-for="exam in data.exams" :key="exam.id" :value="exam.id">{{ exam.name }}</option></select></label><div class="form-grid"><label><span>英文眉题</span><input v-model="templateForm.eyebrow" maxlength="60"></label><label><span>中文主标题</span><input v-model="templateForm.title" maxlength="80" required></label></div><label><span>通知书正文</span><textarea v-model="templateForm.body" rows="9" maxlength="1600" required></textarea></label><label><span>页脚说明</span><textarea v-model="templateForm.footer" rows="3" maxlength="300"></textarea></label><div class="form-grid"><label><span>学校主色</span><input v-model="templateForm.primaryColor" type="color"></label><label><span>强调色</span><input v-model="templateForm.accentColor" type="color"></label></div><button class="app-button app-button--primary" :disabled="saving">保存并启用模板</button></form><article class="notice-template-preview" :style="{ '--template-primary': templateForm.primaryColor, '--template-accent': templateForm.accentColor }"><div><small>{{ templateForm.eyebrow }}</small><h2>{{ templateForm.title }}</h2><h3>{{ data.school?.name }}</h3><em>通知书编号AD01-EX-2026-ZK-000001</em><strong>张同学</strong><p>{{ previewBody }}</p><footer><span>{{ templateForm.footer }}</span><b>{{ data.school?.name }}</b></footer><i>防伪二维码</i></div><p>右侧为 A4 通知书预览正式件会自动写入编号防伪查询码与二维码</p></article></section>
</PageState>
</PortalShell>
</template>
@@ -0,0 +1,202 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import PortalShell from '@/layouts/PortalShell.vue';
import PageState from '@/components/common/PageState.vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import ProfileFields from '@/components/common/ProfileFields.vue';
import AccountSecurity from '@/components/common/AccountSecurity.vue';
import { api } from '@/lib/api';
import { dateRange, formatDate, money, passPolicyText } from '@/lib/format';
import { sessionStore } from '@/stores/session';
import { uiStore } from '@/stores/ui';
const props = defineProps({ page: { type: String, required: true } });
const router = useRouter();
const loading = ref(true);
const error = ref('');
const data = ref({});
const selectedSubjects = reactive({});
const appealReasons = reactive({});
const preferenceDrafts = reactive({});
const profileForm = reactive({});
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const saving = ref(false);
const meta = computed(() => ({
dashboard: ['总览', '查看资料、报名、准考证与成绩状态。'], profile: ['个人资料', '维护实名、学籍和联系信息。'],
exams: ['考试报名', '在开放时间内选择考试和报考科目。'], registrations: ['我的报名', '查看考试、科目和审核进度。'],
admit: ['准考证', '在规定时间内下载已经生成的准考证。'], results: ['成绩查询', '查看正式发布的成绩并申请复议。'],
admissions: ['志愿填报与录取', '填报本人志愿并查看投档与录取进度。'], notices: ['通知公告', '查看与考试相关的最新通知。'],
security: ['账户安全', '修改登录密码并管理二次验证。'], onboarding: ['首次登录', '完成密码更新和个人资料建档。']
}[props.page] || ['考生中心', '办理个人考试事项。']));
const endpoint = computed(() => ({
dashboard: 'dashboard', profile: 'profile', exams: 'exams', registrations: 'registrations', admit: 'registrations',
results: 'results', admissions: 'admissions', notices: 'notices'
}[props.page]));
const registrations = computed(() => data.value.registrations || (Array.isArray(data.value) ? data.value : []));
const groupedResults = computed(() => Object.values((data.value.results || []).reduce((groups, item) => {
(groups[item.examId] ||= []).push(item); return groups;
}, {})));
const classesForSchool = computed(() => (data.value.classes || []).filter(item => item.schoolId === profileForm.schoolId));
async function load() {
loading.value = true; error.value = '';
try {
if (props.page === 'onboarding') {
if (sessionStore.state.user?.mustChangePassword) data.value = { stage: 'password' };
else data.value = await api('/api/candidate/profile');
} else if (props.page === 'security') data.value = await api('/api/auth/totp');
else data.value = await api(`/api/candidate/${endpoint.value}`);
hydrate();
} catch (requestError) { error.value = requestError.message; }
finally { loading.value = false; }
}
function hydrate() {
const profile = data.value.profile || {};
Object.keys(profileForm).forEach(key => delete profileForm[key]);
Object.assign(profileForm, profile, { idNumber: String(profile.idNumber || '').startsWith('PENDING-') ? '' : profile.idNumber || '' });
for (const exam of data.value.exams || []) selectedSubjects[exam.id] = [...(exam.registration?.subjectIds || [])];
for (const item of data.value.admissions || []) {
const existing = item.preference?.payload?.choices || [];
const indicator = existing.find(choice => choice.preferenceType === 'indicator') || { schoolId: '', categoryCode: '', preferenceType: 'indicator' };
const general = existing.filter(choice => choice.preferenceType !== 'indicator');
preferenceDrafts[item.examId] = [indicator, ...Array.from({ length: Number(item.payload?.maxChoices || 5) }, (_, index) => ({
schoolId: general[index]?.schoolId || '', categoryCode: general[index]?.categoryCode || '', preferenceType: 'general'
}))];
}
}
async function changePassword(onboarding = false) {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { error.value = '两次输入的新密码不一致'; return; }
saving.value = true; error.value = '';
try {
await api('/api/auth/change-password', { method: 'POST', body: passwordForm });
Object.assign(passwordForm, { currentPassword: '', newPassword: '', confirmPassword: '' });
await sessionStore.refreshSession();
uiStore.notify('密码修改成功', onboarding ? '请继续补全个人资料' : '下次登录请使用新密码');
if (onboarding) await load();
} catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; }
}
async function saveProfile(onboarding = false) {
saving.value = true; error.value = '';
try {
const fields = ['name','gender','idNumber','birthDate','nativePlace','ethnicity','schoolId','classId','provinceCode','cityCode','districtCode','phone','email','address','postalCode','guardianName','guardianPhone','emergencyContact','emergencyPhone','specialtyCategory','specialtyType','specialtyCertificate','policyEligibility'];
const body = Object.fromEntries(fields.map(key => [key, profileForm[key] ?? '']));
const response = await api('/api/candidate/profile', { method: 'PUT', body });
sessionStore.state.profile = response.profile;
await sessionStore.refreshSession();
uiStore.notify('资料已提交', '管理员审核后会更新状态');
if (onboarding) await router.replace('/candidate/dashboard'); else await load();
} catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; }
}
async function registerExam(exam) {
const subjectIds = selectedSubjects[exam.id] || [];
if (!subjectIds.length) { uiStore.notify('请选择科目', '至少选择一个报考科目', 'warning'); return; }
saving.value = true;
try {
await api('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds } });
uiStore.notify('报名已提交', `已选择 ${subjectIds.length} 个科目`);
await load();
} catch (requestError) { error.value = requestError.message; }
finally { saving.value = false; }
}
async function submitAppeal(result) {
const reason = String(appealReasons[result.id] || '').trim();
if (reason.length < 5) { uiStore.notify('请补充复议理由', '至少填写 5 个字', 'warning'); return; }
try {
await api(`/api/candidate/results/${result.id}/appeals`, { method: 'POST', body: { reason } });
appealReasons[result.id] = '';
uiStore.notify('成绩复议已提交', '可在本页查看处理进度');
await load();
} catch (requestError) { error.value = requestError.message; }
}
function availablePlans(item, type) {
return (item.plans || []).filter(plan => (plan.categories || []).some(category => (category.preferenceTypes || []).includes(type)));
}
function categoriesFor(item, choice) {
const plan = (item.plans || []).find(row => row.schoolId === choice.schoolId);
return (plan?.categories || []).filter(category => (category.preferenceTypes || []).includes(choice.preferenceType));
}
async function savePreferences(item) {
const choices = (preferenceDrafts[item.examId] || []).filter(choice => choice.schoolId && choice.categoryCode);
try {
const response = await api(`/api/candidate/admissions/${item.examId}/preferences`, { method: 'PUT', body: { choices } });
uiStore.notify(response.locked ? '志愿已保存并锁定' : '志愿已保存', response.locked ? '提交次数已达到上限' : `还可提交 ${response.remainingSubmissions}`);
await load();
} catch (requestError) { error.value = requestError.message; }
}
function downloadAdmit(id) { window.location.href = `/api/candidate/registrations/${id}/admit-card`; }
async function downloadScore(items) {
const summary = (data.value.summaries || []).find(item => item.examId === items[0].examId) || {};
const { downloadScoreReport } = await import(/* @vite-ignore */ '/js/client/pdf-export.js');
await downloadScoreReport({
organization: sessionStore.state.publicData.organization,
candidate: data.value.candidate || { name: sessionStore.state.profile?.name || sessionStore.state.user?.displayName, candidateNumber: sessionStore.state.user?.candidateNumber },
exam: { id: items[0].examId, name: items[0].examName, code: items[0].examCode }, results: items,
summary: { ...summary, publishedAt: [...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0]?.publishedAt },
verificationCode: summary.verificationCode, verificationQr: summary.verificationQr,
verificationUrl: `${location.origin}/verify/${summary.verificationCode}`
});
}
async function downloadAdmission(item) {
const { downloadAdmissionNotice } = await import(/* @vite-ignore */ '/js/client/pdf-export.js');
await downloadAdmissionNotice({
organization: sessionStore.state.publicData.organization,
candidate: { name: sessionStore.state.profile?.name || sessionStore.state.user?.displayName }, exam: item.exam,
placement: item.placement, school: item.placementSchool || { name: item.placement?.schoolName || '招生学校' }, template: item.noticeTemplate || {},
verificationCode: item.noticeVerificationCode, verificationQr: item.noticeVerificationQr, noticeNumber: item.noticeNumber,
verificationUrl: `${location.origin}/verify/${item.noticeVerificationCode}`
});
}
function openNotice(id) { router.push(`/announcements/${id}`); }
watch(() => props.page, load);
onMounted(load);
</script>
<template>
<main v-if="page === 'onboarding'" class="candidate-onboarding">
<aside><RouterLink class="app-brand app-brand--light" to="/"><span></span><div><strong>衡准考试服务</strong><small>FIRST SIGN-IN</small></div></RouterLink><p>固定报名号</p><strong>{{ sessionStore.state.user?.candidateNumber }}</strong><span>完成首次登录设置后这个号码将用于所有考试事项</span></aside>
<section><PageState :loading="loading" :error="error" @retry="load">
<form v-if="sessionStore.state.user?.mustChangePassword" class="business-form onboarding-form" @submit.prevent="changePassword(true)"><p>STEP 1</p><h1>先保护你的账户</h1><span>初始密码只用于第一次登录,请设置仅本人知道的新密码。</span><label><span>当前初始密码</span><input v-model="passwordForm.currentPassword" type="password" required></label><label><span>设置新密码</span><input v-model="passwordForm.newPassword" type="password" minlength="8" required></label><label><span>再次输入新密码</span><input v-model="passwordForm.confirmPassword" type="password" minlength="8" required></label><button class="app-button app-button--primary app-button--large" :disabled="saving">保存新密码并继续</button></form>
<form v-else class="business-form profile-editor" @submit.prevent="saveProfile(true)"><p>STEP 2</p><h1>建立完整考生档案</h1><ProfileFields :form="profileForm" :schools="data.schools || []" :classes="classesForSchool" /><button class="app-button app-button--primary app-button--large" :disabled="saving">提交个人信息</button></form>
</PageState></section>
</main>
<PortalShell v-else role="candidate" :page="page" :title="meta[0]" :description="meta[1]">
<PageState :loading="loading" :error="error" @retry="load">
<template v-if="page === 'dashboard'">
<section class="candidate-welcome-vue"><div><span>{{ new Date().getHours() < 12 ? '上午好' : '下午好' }}</span><h2>{{ data.profile?.name || sessionStore.state.user?.displayName }}欢迎回来</h2><p>{{ data.profile?.status === 'approved' ? '资料已通过审核,可以继续办理考试事项。' : '个人资料正在审核中,通过后即可报名考试。' }}</p></div><strong><br></strong></section>
<section class="record-metrics"><article><span>个人资料</span><strong><StatusBadge :value="data.profile?.status || 'pending'" /></strong></article><article><span>已报名考试</span><strong>{{ data.registrations?.length || 0 }}</strong></article><article><span>可下载准考证</span><strong>{{ data.registrations?.filter(item => item.admitCard).length || 0 }}</strong></article><article><span>已发布成绩</span><strong>{{ data.results?.length || 0 }}</strong></article></section>
<div class="candidate-dashboard-grid"><section class="record-panel"><header><div><h2>最近报名</h2><p>考试办理状态实时更新</p></div></header><button v-for="item in data.registrations?.slice(0, 4)" :key="item.id" class="dashboard-row" type="button" @click="router.push('/candidate/registrations')"><span><strong>{{ item.exam?.name }}</strong><small>{{ item.subjects?.length || 0 }} 个科目</small></span><StatusBadge :value="item.status" /></button></section><section class="record-panel"><header><div><h2>最近通知</h2><p>考试中心正式发布</p></div></header><button v-for="notice in data.notices?.slice(0, 5)" :key="notice.id" class="dashboard-row" type="button" @click="openNotice(notice.id)"><span><strong>{{ notice.title }}</strong><small>{{ formatDate(notice.publishAt) }}</small></span><i></i></button></section></div>
</template>
<form v-else-if="page === 'profile'" class="business-form profile-editor" @submit.prevent="saveProfile(false)"><ProfileFields :form="profileForm" :schools="data.schools || []" :classes="classesForSchool" /><div v-if="data.profile?.reviewNote" class="form-callout"><strong>审核意见</strong><p>{{ data.profile.reviewNote }}</p></div><button class="app-button app-button--primary" :disabled="saving">保存并提交审批</button></form>
<div v-else-if="page === 'exams'" class="business-card-list"><article v-for="exam in data.exams" :key="exam.id" class="exam-apply-card"><header><span>{{ exam.code }}</span><StatusBadge :value="exam.registrationState" /></header><h2>{{ exam.name }}</h2><p>{{ exam.description }}</p><dl><div><dt>报名期限</dt><dd>{{ dateRange(exam.registrationStart, exam.registrationEnd) }}</dd></div><div><dt>考试时间</dt><dd>{{ dateRange(exam.examStart, exam.examEnd) }}</dd></div><div><dt>计分规则</dt><dd>总分 {{ exam.totalScore }} · {{ passPolicyText(exam) }}</dd></div></dl><div class="subject-choice-grid"><label v-for="subject in exam.subjects" :key="subject.id"><input v-model="selectedSubjects[exam.id]" type="checkbox" :value="subject.id" :disabled="Boolean(exam.registration)"><span><strong>{{ subject.name }}</strong><small>{{ subject.date }} {{ subject.start }} · 满分 {{ subject.fullScore }}</small><em>{{ money(subject.fee) }}</em></span></label></div><footer v-if="exam.registration"><span>已提交 {{ exam.registration.subjectIds?.length || 0 }} 个科目</span><StatusBadge :value="exam.registration.status" /></footer><button v-else class="app-button app-button--primary" type="button" :disabled="saving || exam.registrationState !== 'open' || data.profileStatus !== 'approved'" @click="registerExam(exam)">{{ data.profileStatus !== 'approved' ? '资料审核通过后可报名' : '提交考试报名' }}</button></article></div>
<div v-else-if="page === 'registrations'" class="business-card-list"><article v-for="item in registrations" :key="item.id" class="registration-vue-card"><header><div><span>{{ item.exam?.code }}</span><h2>{{ item.exam?.name }}</h2></div><StatusBadge :value="item.exam?.archivedAt ? 'archived' : item.status" /></header><dl><div><dt>账户报名号</dt><dd>{{ item.registrationNumber || sessionStore.state.user?.candidateNumber }}</dd></div><div><dt>当前审批</dt><dd>{{ item.workflow?.currentStepDetail?.name || item.workflow?.status || '待提交' }}</dd></div><div><dt>应缴金额</dt><dd>{{ money(item.amountDue) }}</dd></div><div><dt>缴费状态</dt><dd><StatusBadge :value="item.paymentStatus" /></dd></div></dl><div class="chip-list"><span v-for="subject in item.subjects" :key="subject.id">{{ subject.name }}<small>{{ subject.date }} {{ subject.start }}</small></span></div><p v-if="item.reviewNote">审核意见:{{ item.reviewNote }}</p></article><div v-if="!registrations.length" class="page-state page-state--empty"><strong>还没有考试报名</strong><p>资料审核通过后可在考试报名中选择考试与科目</p></div></div>
<div v-else-if="page === 'admit'" class="business-card-list"><article v-for="item in registrations.filter(row => row.admitCard)" :key="item.id" class="admit-card-vue"><header><span>{{ item.exam.code }}</span><StatusBadge :value="item.exam.archivedAt ? 'archived' : 'open'" /></header><h2>{{ item.exam.name }}</h2><div><small>准考证号</small><strong>{{ item.admitCard.number }}</strong></div><dl><div><dt>固定考点</dt><dd>{{ item.admitCard.testCenter }}</dd></div><div><dt>下载时间</dt><dd>{{ dateRange(item.exam.admitDownloadStart, item.exam.admitDownloadEnd) }}</dd></div></dl><button class="app-button app-button--primary" type="button" :disabled="Boolean(item.exam.archivedAt)" @click="downloadAdmit(item.id)">下载准考证</button></article><div v-if="!registrations.some(row => row.admitCard)" class="page-state page-state--empty"><strong>准考证尚未生成</strong><p>管理员统一编排后会显示在这里</p></div></div>
<div v-else-if="page === 'results'" class="business-card-list"><section v-for="items in groupedResults" :key="items[0].examId" class="record-panel result-group"><header><div><span>{{ items[0].examCode }}</span><h2>{{ items[0].examName }}</h2></div><button class="app-button app-button--primary" type="button" @click="downloadScore(items)">下载 PDF 成绩单</button></header><div class="result-card-grid"><article v-for="result in items" :key="result.id"><span>{{ result.subjectName }}</span><strong>{{ result.score }}<small>/ {{ result.fullScore }}</small></strong><em>{{ result.grade }} · 第 {{ result.rank }} / {{ result.cohortSize }} 名</em><StatusBadge v-if="result.appeal" :value="result.appeal.status" /><form v-else @submit.prevent="submitAppeal(result)"><textarea v-model="appealReasons[result.id]" rows="2" placeholder="填写成绩复议理由"></textarea><button type="submit">申请复议</button></form></article></div></section><div v-if="!groupedResults.length" class="page-state page-state--empty"><strong>暂时没有已发布成绩</strong><p>成绩发布后会显示在这里</p></div></div>
<div v-else-if="page === 'admissions'" class="business-card-list"><section v-for="item in data.admissions" :key="item.examId" class="record-panel admission-candidate-vue"><header><div><span>{{ item.exam.code }} · 第 {{ item.payload?.round || 1 }} 轮</span><h2>{{ item.exam.name }}</h2></div><StatusBadge :value="item.status" /></header><div class="record-metrics"><article><span>本场总成绩</span><strong>{{ item.totalScore ?? '未完整发布' }}</strong></article><article><span>特征分</span><strong>{{ item.featureScore || 0 }}</strong></article><article><span>已提交志愿</span><strong>{{ item.submissionCount || 0 }} / {{ item.maxSubmissions || item.payload?.maxSubmissions || 0 }}</strong></article></div><div v-if="item.placement" class="form-callout"><strong>当前录取结果:{{ item.placementSchool?.name || item.placement.schoolName || '招生学校' }}</strong><p>{{ item.placement.payload?.categoryName }} · {{ item.placement.status }}</p><button v-if="item.placement.status === 'final'" class="app-button app-button--primary" type="button" @click="downloadAdmission(item)">下载录取通知书 PDF</button></div><form v-if="['filling','supplementary'].includes(item.status) && !item.preferenceLocked && item.supplementEligible !== false" class="preference-editor" @submit.prevent="savePreferences(item)"><div v-for="(choice, index) in preferenceDrafts[item.examId]" :key="index" class="preference-row"><b>{{ choice.preferenceType === 'indicator' ? '指标' : index }}</b><select v-model="choice.schoolId" @change="choice.categoryCode = ''"><option value="">选择招生学校</option><option v-for="plan in availablePlans(item, choice.preferenceType)" :key="plan.schoolId" :value="plan.schoolId">{{ plan.schoolCode }} · {{ plan.schoolName }}</option></select><select v-model="choice.categoryCode" :disabled="!choice.schoolId"><option value="">选择招生类别</option><option v-for="category in categoriesFor(item, choice)" :key="category.code" :value="category.code">{{ category.name }}</option></select></div><button class="app-button app-button--primary">保存本人志愿</button></form><p v-else>{{ item.supplementIneligibilityReason || item.payload?.progress || '当前阶段不能修改志愿。' }}</p></section><div v-if="!data.admissions?.length" class="page-state page-state--empty"><strong>暂无志愿填报安排</strong><p>成绩发布且考试启用志愿后会显示在这里</p></div></div>
<section v-else-if="page === 'notices'" class="record-panel notice-list-vue"><button v-for="notice in data.notices" :key="notice.id" type="button" @click="openNotice(notice.id)"><time>{{ formatDate(notice.publishAt) }}</time><span><em>{{ notice.category }}</em><strong>{{ notice.title }}</strong><small>{{ notice.summary }}</small></span><i></i></button></section>
<AccountSecurity v-else-if="page === 'security'" :status="data" @updated="load" />
</PageState>
</PortalShell>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PublicFrame from '@/components/common/PublicFrame.vue';
import PageState from '@/components/common/PageState.vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/format';
import { buildPublicDocuments } from '@/lib/publicDocuments';
import { sessionStore } from '@/stores/session';
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const error = ref('');
const document = ref(null);
async function load() {
loading.value = true;
error.value = '';
try {
const id = String(route.params.id);
const data = await api('/api/public/announcements');
document.value = buildPublicDocuments(sessionStore.state.publicData, data).find(item => item.documentId === id) || null;
if (document.value?.documentType === 'notice') {
const detail = await api(`/api/public/notices/${encodeURIComponent(id)}`);
document.value = { ...document.value, ...detail.notice };
}
if (!document.value) throw new Error('公告不存在或尚未公开');
} catch (requestError) {
error.value = requestError.message;
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<PublicFrame>
<section class="app-container document-page">
<button class="document-page__back" type="button" @click="router.push('/announcements')"> 返回公开信息目录</button>
<PageState :loading="loading" :error="error" @retry="load">
<article v-if="document" class="public-document">
<header><span>{{ document.category }} · {{ document.subtype }}</span><h1>{{ document.title }}</h1><p>{{ formatDate(document.publishedAt || document.publishAt, true) }}<template v-if="document.author"> · {{ document.author }}</template></p></header>
<section v-if="document.documentType === 'notice'" class="document-richtext" v-html="document.contentHtml || `<p>${String(document.content || '').replaceAll('\n', '</p><p>')}</p>`"></section>
<section v-else-if="document.documentType === 'reporting'" class="document-reporting">
<div class="record-metrics"><article><span>招生计划</span><strong>{{ document.statistics?.totalQuota || 0 }}</strong></article><article><span>正式录取</span><strong>{{ document.statistics?.finalCount || 0 }}</strong></article><article><span>已报到</span><strong>{{ document.statistics?.reportedCount || 0 }}</strong></article><article><span>完成率</span><strong>{{ document.statistics?.reportingRate || 0 }}%</strong></article></div>
<p>{{ document.decisionNote || document.summary }}</p>
</section>
<section v-else class="document-table-wrap">
<p>{{ document.summary }}</p>
<table v-if="document.documentType === 'plan'"><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>定向指标</th></tr></thead><tbody><tr v-for="row in document.rows" :key="row.code"><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.quota }}</td><td>{{ row.indicatorQuota || 0 }}</td></tr></tbody></table>
<table v-else-if="document.documentType === 'qualification'"><thead><tr><th>报名号</th><th>姓名</th><th>指标资格</th><th>特长类型</th></tr></thead><tbody><tr v-for="row in document.rows" :key="row.registrationNumber"><td>{{ row.registrationNumber }}</td><td>{{ row.name }}</td><td><StatusBadge :value="row.eligible ? 'approved' : 'rejected'" /></td><td>{{ row.specialtyLabel || '普通生' }}</td></tr></tbody></table>
<table v-else-if="document.documentType === 'admission'"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody><tr v-for="row in document.rows" :key="row.registrationNumber"><td>{{ row.registrationNumber }}</td><td>{{ row.name }}</td><td>{{ row.totalScore }}</td><td>{{ row.admittedSchool }}</td><td>{{ row.categoryName }}</td></tr></tbody></table>
<table v-else><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>分数线</th></tr></thead><tbody><tr v-for="row in document.rows" :key="`${row.schoolName}-${row.categoryName}`"><td>{{ row.schoolName }}</td><td>{{ row.categoryName }}</td><td>{{ row.planQuota }}</td><td>{{ row.admittedCount }}</td><td>{{ row.highestScore }}</td><td><strong>{{ row.cutoffScore }}</strong></td></tr></tbody></table>
</section>
</article>
</PageState>
</section>
</PublicFrame>
</template>
@@ -0,0 +1,82 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import PublicFrame from '@/components/common/PublicFrame.vue';
import PageState from '@/components/common/PageState.vue';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/format';
import { buildPublicDocuments } from '@/lib/publicDocuments';
import { sessionStore } from '@/stores/session';
const router = useRouter();
const loading = ref(true);
const error = ref('');
const documents = ref([]);
const query = ref('');
const category = ref('全部');
const page = ref(1);
const pageSize = 10;
const categories = computed(() => ['全部', ...new Set(documents.value.map(item => item.category || '通知公告'))]);
const filtered = computed(() => {
const needle = query.value.trim().toLowerCase();
return documents.value.filter(item => (category.value === '全部' || item.category === category.value)
&& (!needle || [item.title, item.summary, item.category, item.subtype].join(' ').toLowerCase().includes(needle)));
});
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize)));
const pageRows = computed(() => filtered.value.slice((page.value - 1) * pageSize, page.value * pageSize));
async function load() {
loading.value = true;
error.value = '';
try {
const data = await api('/api/public/announcements');
documents.value = buildPublicDocuments(sessionStore.state.publicData, data);
} catch (requestError) {
error.value = requestError.message;
} finally {
loading.value = false;
}
}
function chooseCategory(value) { category.value = value; page.value = 1; }
function search() { page.value = 1; }
onMounted(load);
</script>
<template>
<PublicFrame>
<section class="public-page-head">
<div class="app-container"><p>PUBLIC RECORDS</p><h1>通知公告与公开公示</h1><span>考试通知成绩发布招生计划和录取公示统一归档</span></div>
</section>
<section class="app-container public-directory">
<PageState :loading="loading" :error="error" :empty="!documents.length" @retry="load">
<aside class="public-directory__filters">
<strong>信息分类</strong>
<button v-for="item in categories" :key="item" :class="{ active: category === item }" type="button" @click="chooseCategory(item)">
{{ item }}<span>{{ item === '全部' ? documents.length : documents.filter(row => row.category === item).length }}</span>
</button>
</aside>
<div class="public-directory__content">
<div class="directory-toolbar">
<label><span>搜索公开信息</span><input v-model="query" placeholder="输入标题、分类或摘要" @input="search"></label>
<small> {{ filtered.length }} </small>
</div>
<div class="directory-list">
<button v-for="item in pageRows" :key="item.documentId" type="button" @click="router.push(`/announcements/${item.documentId}`)">
<time><strong>{{ String(new Date(item.publishedAt).getDate()).padStart(2, '0') }}</strong><span>{{ formatDate(item.publishedAt).slice(0, 7) }}</span></time>
<span><em>{{ item.subtype }}</em><strong>{{ item.title }}</strong><small>{{ item.summary || '进入查看完整公开内容' }}</small></span>
<i></i>
</button>
<div v-if="!pageRows.length" class="page-state page-state--empty"><strong>没有符合条件的信息</strong><p>请调整分类或搜索关键词</p></div>
</div>
<nav v-if="totalPages > 1" class="app-pagination" aria-label="公告分页">
<button type="button" :disabled="page <= 1" @click="page--">上一页</button>
<span> {{ page }} / {{ totalPages }} </span>
<button type="button" :disabled="page >= totalPages" @click="page++">下一页</button>
</nav>
</div>
</PageState>
</section>
</PublicFrame>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import { sessionStore } from '@/stores/session';
const props = defineProps({ mode: { type: String, required: true } });
const route = useRoute();
const router = useRouter();
const busy = ref(false);
const error = ref('');
const challenge = ref('');
const issuedNumber = ref('');
const login = reactive({ username: '', password: '', code: '' });
const register = reactive({ name: '', gender: '', schoolId: '', classId: '', password: '' });
const schools = computed(() => sessionStore.state.publicData.schools || []);
const classes = computed(() => (sessionStore.state.publicData.classes || []).filter(item => item.schoolId === register.schoolId));
const registrationEnabled = computed(() => Boolean(sessionStore.state.publicData.selfRegistrationEnabled));
async function submitLogin() {
busy.value = true; error.value = '';
try {
const data = challenge.value
? await api('/api/auth/login/totp', { method: 'POST', body: { challenge: challenge.value, code: login.code } })
: await api('/api/auth/login', { method: 'POST', body: { username: login.username, password: login.password } });
if (data.requiresTotp) { challenge.value = data.challenge; return; }
sessionStore.setSession(data);
await sessionStore.refreshSession();
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : sessionStore.homeFor(data.user);
await router.replace(redirect);
} catch (requestError) { error.value = requestError.message; }
finally { busy.value = false; }
}
async function submitRegister() {
busy.value = true; error.value = '';
try {
const data = await api('/api/auth/register', { method: 'POST', body: register });
issuedNumber.value = data.registrationNumber;
} catch (requestError) { error.value = requestError.message; }
finally { busy.value = false; }
}
function syncLoginViewportLock() {
document.documentElement.classList.toggle('auth-login-active', props.mode === 'login');
}
watch(() => props.mode, () => {
error.value = '';
challenge.value = '';
issuedNumber.value = '';
syncLoginViewportLock();
});
onMounted(syncLoginViewportLock);
onUnmounted(() => document.documentElement.classList.remove('auth-login-active'));
</script>
<template>
<main :class="['auth-view', `auth-view--${mode}`]">
<section class="auth-view__identity">
<RouterLink class="app-brand app-brand--light" to="/"><span></span><div><strong>衡准考试服务</strong><small>EXAMINATION INFORMATION SERVICE</small></div></RouterLink>
<div><p>CANDIDATE SERVICE</p><h1><template v-if="mode === 'login'"><span>一个报名号</span><span>办理每一次考试</span></template><template v-else><span>申请长期使用的</span><span>固定报名号</span></template></h1><span>报名号就是考生账户不因考试科目或年度报名而改变</span></div>
<small>统一身份 · 全程留痕 · 文书可核验</small>
</section>
<section class="auth-view__panel">
<RouterLink class="auth-view__back" to="/"> 返回首页</RouterLink>
<form v-if="mode === 'login'" class="auth-card" @submit.prevent="submitLogin">
<p>{{ challenge ? 'SECOND STEP' : 'ACCOUNT LOGIN' }}</p>
<h2>{{ challenge ? '输入动态验证码' : '报名号登录' }}</h2>
<span>{{ challenge ? '输入验证器当前显示的 6 位验证码,或使用一个恢复码。' : '考生填写报名号和密码;管理员使用管理账号。' }}</span>
<div v-if="error" class="form-error">{{ error }}</div>
<template v-if="!challenge"><label><span>报名号 / 管理员账号</span><input v-model="login.username" autocomplete="username" required></label><label><span>密码</span><input v-model="login.password" type="password" autocomplete="current-password" required></label></template>
<label v-else><span>动态验证码或恢复码</span><input v-model="login.code" autocomplete="one-time-code" required autofocus></label>
<button class="app-button app-button--primary app-button--large" type="submit" :disabled="busy">{{ busy ? '正在处理…' : challenge ? '验证并登录' : '登录系统' }}</button>
<button v-if="challenge" class="app-link-button" type="button" @click="challenge = ''; login.code = ''">返回账号密码登录</button>
<p v-if="registrationEnabled" class="auth-card__switch">还没有报名号?<RouterLink to="/auth/register">在线申请</RouterLink></p>
</form>
<form v-else-if="registrationEnabled && !issuedNumber" class="auth-card" @submit.prevent="submitRegister">
<p>CANDIDATE NUMBER</p><h2>申请固定报名号</h2><span>提交基础学籍范围后系统会生成长期使用的报名号</span>
<div v-if="error" class="form-error">{{ error }}</div>
<div class="form-grid"><label><span>考生姓名</span><input v-model="register.name" required></label><label><span>性别</span><select v-model="register.gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label><label><span>就读学校</span><select v-model="register.schoolId" required @change="register.classId = ''"><option value="">请选择</option><option v-for="school in schools" :key="school.id" :value="school.id">{{ school.name }}</option></select></label><label><span>班级</span><select v-model="register.classId" required><option value="">请选择</option><option v-for="item in classes" :key="item.id" :value="item.id">{{ item.name }}</option></select></label></div>
<label><span>设置登录密码</span><input v-model="register.password" type="password" minlength="8" required></label>
<button class="app-button app-button--primary app-button--large" type="submit" :disabled="busy">{{ busy ? '正在生成…' : '生成我的报名号' }}</button>
<p class="auth-card__switch">已有报名号<RouterLink to="/auth/login">返回登录</RouterLink></p>
</form>
<section v-else-if="issuedNumber" class="auth-card issued-card"><p>CANDIDATE NUMBER</p><h2>请保存你的报名号</h2><strong>{{ issuedNumber }}</strong><span>以后报名不同考试仍使用这个号码,请立即抄写并安全保存。</span><RouterLink class="app-button app-button--primary app-button--large" to="/auth/login">前往登录</RouterLink></section>
<section v-else class="auth-card"><p>REGISTRATION CLOSED</p><h2>自主注册暂未开放</h2><span>请联系学校领取报名号和初始密码</span><RouterLink class="app-button app-button--primary app-button--large" to="/auth/login">返回登录</RouterLink></section>
</section>
</main>
</template>
@@ -0,0 +1,16 @@
<script setup>
import HomePage from '@/components/HomePage.vue';
import { sessionStore } from '@/stores/session';
async function logout() {
await sessionStore.logout();
}
</script>
<template>
<HomePage
:public-data="sessionStore.state.publicData"
:session="sessionStore.state"
@logout="logout"
/>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<main class="route-message">
<span>404</span>
<h1>没有找到这个页面</h1>
<p>地址可能已经调整你可以返回首页或从业务中心重新进入</p>
<button type="button" @click="router.push('/')">返回首页</button>
</main>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PublicFrame from '@/components/common/PublicFrame.vue';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/format';
const route = useRoute();
const router = useRouter();
const code = ref(String(route.params.code || ''));
const loading = ref(false);
const result = ref(null);
const error = ref('');
async function verify(value = code.value) {
const normalized = value.trim().toUpperCase();
if (!normalized) return;
if (String(route.params.code || '') !== normalized) {
await router.push(`/verify/${encodeURIComponent(normalized)}`);
return;
}
loading.value = true;
result.value = null;
error.value = '';
try {
result.value = await api(`/api/public/verifications/${encodeURIComponent(normalized)}`);
} catch (requestError) {
error.value = requestError.message;
} finally {
loading.value = false;
}
}
watch(() => route.params.code, value => { code.value = String(value || ''); if (value) verify(String(value)); });
onMounted(() => { if (route.params.code) verify(String(route.params.code)); });
</script>
<template>
<PublicFrame>
<section class="verification-page app-container">
<div class="verification-page__intro"><p>DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><span>核对成绩单和录取通知书的系统签发记录</span></div>
<form class="verification-form" @submit.prevent="verify()"><label><span>防伪查询码</span><input v-model="code" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button type="submit" :disabled="loading">{{ loading ? '正在核验' : '立即核验' }}</button></form>
<section v-if="result?.document" class="verification-result is-valid"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>查询码与系统签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>{{ result.document.typeName }}</dd></div><div><dt>考生</dt><dd>{{ result.document.candidateName }}</dd></div><div><dt>考试</dt><dd>{{ result.document.examName }}</dd></div><div v-if="result.document.schoolName"><dt>录取学校</dt><dd>{{ result.document.schoolName }}</dd></div><div><dt>签发时间</dt><dd>{{ formatDate(result.document.issuedAt, true) }}</dd></div></dl></section>
<section v-else-if="error" class="verification-result is-invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>{{ error }}</p></div></section>
<aside class="verification-safety"><strong>安全提示</strong><p>查询结果只展示脱敏身份与文书摘要请勿在非官方页面提交密码或验证码</p></aside>
</section>
</PublicFrame>
</template>
+1 -3
View File
@@ -15,9 +15,7 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://127.0.0.1:4173', '/api': 'http://127.0.0.1:4173',
'/health': 'http://127.0.0.1:4173', '/health': 'http://127.0.0.1:4173',
'/js': 'http://127.0.0.1:4173', '/js': 'http://127.0.0.1:4173'
'/vendor': 'http://127.0.0.1:4173',
'/styles.css': 'http://127.0.0.1:4173'
} }
}, },
build: { build: {
-6
View File
@@ -6,7 +6,6 @@
<meta name="theme-color" content="#0d2d54" /> <meta name="theme-color" content="#0d2d54" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" /> <meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title> <title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/styles.css?v=20260723" />
<link rel="stylesheet" href="/vue-app/app.css?v=20260723" /> <link rel="stylesheet" href="/vue-app/app.css?v=20260723" />
</head> </head>
<body> <body>
@@ -17,11 +16,6 @@
<span>系统正在加载</span> <span>系统正在加载</span>
</div> </div>
</div> </div>
<div id="modalRoot"></div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-icon"></span>
<div><strong>操作成功</strong><small>更改已保存</small></div>
</div>
<script type="module" src="/vue-app/app.js?v=20260723"></script> <script type="module" src="/vue-app/app.js?v=20260723"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
import { specialtyCatalog } from '../data/specialty-types.js';
export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) {
return `<div class="indicator-allocation-row"><label><span>生源学校</span><select name="indicatorSchool"><option value="">请选择生源校</option>${sourceSchools.map(school => `<option value="${h(school.id)}" ${school.id === allocation.sourceSchoolId ? 'selected' : ''}>${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><label><span>分配名额</span><input name="indicatorQuota" type="number" min="1" step="1" value="${h(allocation.quota || '')}" placeholder="人数"></label><button type="button" class="row-action" data-action="remove-indicator-allocation">移除</button></div>`;
}
export function admissionCategoryEditor(h, sourceSchools = [], category = {}) {
const specialty = Boolean(category.specialtyCategory);
const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory);
return `<article class="admission-category-editor"><header><div><span>招生类别</span><strong>${h(category.name || '新类别')}</strong></div><button type="button" data-action="remove-admission-category">移除类别</button></header><div class="admission-category-fields"><label><span>类别名称 *</span><input name="categoryName" required maxlength="80" value="${h(category.name || '')}" placeholder="例如:普通生、艺术特长生"></label><label><span>计划人数 *</span><input name="categoryQuota" required type="number" min="1" step="1" value="${h(category.quota || '')}" placeholder="人数"></label><label><span>类别性质</span><select name="categoryKind" data-action="plan-category-kind"><option value="general" ${specialty ? '' : 'selected'}>普通 / 政策类</option><option value="specialty" ${specialty ? 'selected' : ''}>特长生</option></select></label><div class="specialty-plan-fields ${specialty ? '' : 'hidden'}" data-plan-specialty><label><span>特长大类 *</span><select name="categorySpecialtyCategory" data-action="specialty-category" ${specialty ? '' : 'disabled'}><option value="">请选择大类</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyCategory ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长小类 *</span><select name="categorySpecialtyType" data-specialty-type ${specialty && selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyType ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div></div><section class="indicator-allocation-editor"><div class="indicator-allocation-head"><div><strong>指标分配</strong><small>可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。</small></div><button type="button" class="row-action" data-action="add-indicator-allocation">添加生源校指标</button></div><div data-indicator-allocations>${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}</div></section></article>`;
}
export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) {
const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }];
return `<section class="admission-categories-builder"><div class="admission-builder-head"><div><strong>招生类别与计划</strong><small>逐项设置类别、资格范围和生源校指标。</small></div><button type="button" class="row-action primary" data-action="add-admission-category">添加招生类别</button></div><div data-admission-categories>${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}</div></section>`;
}
@@ -1,87 +0,0 @@
export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
function shell(page, content, title, description) {
const groups = [...new Set(nav.map(item => item[3]))];
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav class="portal-nav-groups">${groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(item => item[3] === group).map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</section>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
}
async function renderAdmission(page) {
if (state.user?.role !== 'admission_school') return requireLogin();
if (!nav.some(item => item[0] === page)) page = 'dashboard';
const meta = { dashboard:['招生工作台','查看本校计划完成率、报到进度与待办事项。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'context' : page;
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : page === 'reporting' ? reporting(data) : noticeTemplate(data);
app.innerHTML = shell(page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
function dashboard(data) {
const progress = data.plans || [];
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section>${progress.length ? `<section class="admission-progress-grid">${progress.map(plan => `<article><header><span>${h(plan.examName)}</span><strong>${h(plan.progress.admissionRate)}%</strong></header><div class="progress-meter"><i style="width:${Math.min(100, plan.progress.admissionRate)}%"></i></div><p>计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人</p><small>实际报到完成率 ${h(plan.progress.reportingRate)}%</small></article>`).join('')}</section>` : ''}<div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button><button data-route="admission_school/reporting"><i>到</i><span><strong>登记考生报到</strong><small>暂存、Excel 导入或扫描通知书二维码</small></span>${icons.arrow}</button></section>${data.notifications?.length ? `<section class="panel compact-notices"><div class="panel-title"><h2>系统自动通知</h2><span>${data.notifications.length} 条</span></div>${data.notifications.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section>` : ''}</div>`;
}
function reporting(data) {
if (!data.batches?.length) return `<section class="panel empty-state"><h2>暂无报到批次</h2><p>超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。</p></section>`;
const statusLabels = { draft: '暂存中', submitted: '报到已提交', pending_approval: '补录决定待审批', approved: '已审批并公示', rejected: '审批退回', not_started: '尚未开始' };
return data.batches.map(batch => {
const key = `reporting-${batch.exam.id}-${batch.round}`;
const page = paged(batch.rows, key, 20);
const editable = ['draft', 'rejected'].includes(batch.status);
const importSummary = state.reportingImportSummaries?.[batch.exam.id];
const rowHtml = page.items.map(item => `<tr>${editable ? `<td class="selection-cell"><input type="checkbox" data-reporting-select value="${h(item.placementId)}" aria-label="选择 ${h(item.name)}"></td>` : ''}<td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
const bulkTools = editable ? `<div class="reporting-bulk-bar" data-reporting-bulk data-table-id="${h(key)}"><label class="bulk-check"><input type="checkbox" data-reporting-select-all data-table-id="${h(key)}"><span>全选本页</span></label><strong data-reporting-selected-count>已选 0 人</strong><label><span>统一状态</span><select data-reporting-bulk-status><option value="reported">Y · 确认报到</option><option value="not_reported">N · 确认未报到</option><option value="pending">P · 待确认</option></select></label><label class="bulk-note"><span>统一备注(留空则保留原备注)</span><input data-reporting-bulk-note placeholder="例如:现场核验通过"></label><button type="button" class="ghost-button" data-action="bulk-reporting-apply">应用到所选</button></div>` : '';
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div>${bulkTools}<div class="table-scroll"><table id="${h(key)}"><thead><tr>${editable ? '<th class="selection-cell">选择</th>' : ''}<th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || `<tr><td colspan="${editable ? '5' : '4'}" class="empty-state">本轮没有正式录取考生</td></tr>`}</tbody></table></div>${pagination(page)}`;
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div class="reporting-excel-tool"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><div class="tool-buttons"><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label></div>${importSummary ? `<div class="reporting-import-summary ${importSummary.changedCount ? 'changed' : 'unchanged'}"><strong>${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)}` : '最近导入没有产生变化'}</strong><span>读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行</span>${importSummary.changes?.length ? `<small>${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}${h(item.fromCode)}${h(item.toCode)}`).join('')}</small>` : '<small>Excel 内容与当前暂存状态一致。</small>'}</div>` : ''}</div><div class="reporting-scan-tool"><div><strong>通知书二维码核验</strong><small>打开实时相机扫描;识别后先核对考生,再点击暂存。</small></div><button type="button" class="camera-button" data-action="open-reporting-camera" data-exam-id="${h(batch.exam.id)}">${icons.camera || ''}<span>打开相机扫码</span></button><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="也可粘贴 AN 防伪码或二维码链接" required><button class="ghost-button" type="submit">核验</button></form></div></section>` : ''}${ledgerBlock}</section>`;
}).join('');
}
function paged(items, key, defaultPageSize = 50) {
items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
state.tablePages[key] = { page, pageSize };
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
}
function noticeTemplate(data) {
const template = data.template || {};
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><div class="template-notice-number">通知书编号:AD01-EX-2026-ZK-000001</div><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><div class="template-qr-placeholder">防伪二维码</div></div><p>右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。</p></article></section>`;
}
function pagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function plans(data) {
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolAdmissionPlanTable" placeholder="跨页搜索考试、类别、指标学校或审核意见"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)}`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
}
function placements(data) {
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()];
const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))];
const pendingCount = data.placements.filter(item => item.status === 'school_review').length;
const placementPage = paged(data.placements, 'placementReviewTable');
const rows = placementPage.items.map(item => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="跨页搜索姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
}
return { renderAdmission };
}
import { admissionCategoriesEditor } from './admission-plan-editor.js';
import { specialtyLabel } from '../data/specialty-types.js';
import { filterTableItems } from './table-state.js';
-34
View File
@@ -1,34 +0,0 @@
const pendingReads = new Map();
async function request(path, options) {
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
const response = await fetch(path, {
credentials: 'same-origin',
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
...options,
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
});
const type = response.headers.get('content-type') || '';
const data = type.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(data?.message || '操作未完成,请稍后重试');
error.status = response.status;
throw error;
}
return data;
}
export function api(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
// A quick double click or repeated render must not download and parse the
// same large JSON response more than once while the first request is active.
const key = String(path);
if (pendingReads.has(key)) return pendingReads.get(key);
const loading = request(path, options).finally(() => {
if (pendingReads.get(key) === loading) pendingReads.delete(key);
});
pendingReads.set(key, loading);
return loading;
}
@@ -1,247 +0,0 @@
import { mountRegionSelects } from './region-select.js';
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.js';
export function createCandidateViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
requireLogin,
emptyState,
brand
} = context;
const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
['security', '账户安全', 'user']
];
function adminNavForUser() {
const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security];
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admission-settings', '录取设置', 'check'], ['admission-accounts', '招生账户', 'users'], ['admission-plans', '招生计划', 'exam'], ['admission-reporting', '报到与补录', 'bell'], ['admission-supervision', '投档监督', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security];
}
function portalShell(role, page, content, title, description) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
const groupFor = id => role === 'candidate'
? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他')
: ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'admission-settings': '招生录取', 'admission-accounts': '招生录取', 'admission-plans': '招生录取', 'admission-reporting': '招生录取', 'admission-supervision': '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[id] || '其他');
const groups = [...new Set(nav.map(([id]) => groupFor(id)))];
const navHtml = groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
}
function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'admin' && page === 'schools') return `<button class="solid-button" data-action="new-school">${icons.plus} 创建学校</button>`;
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
return '';
}
function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
}
function mountAdmissionProfileFields(profile = {}) {
const actions = app.querySelector('.profile-form .form-actions');
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
const qualification = resolveProfileSpecialty(profile);
const selectedCategory = specialtyCatalog.find(item => item.code === qualification.category);
actions.insertAdjacentHTML('beforebegin', `<div class="form-section-title" data-admission-profile-fields><span>04</span><div><h2>中考招生资格</h2><p>特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。</p></div></div><div class="form-grid specialty-qualification-grid"><label><span>特长生大类</span><select name="specialtyCategory" data-action="specialty-category"><option value="">无特长生资格</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === qualification.category ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长生小类</span><select name="specialtyType" data-specialty-type ${selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === qualification.type ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长证明编号</span><input name="specialtyCertificate" value="${h(profile.specialtyCertificate || '')}" placeholder="证书或统一测试材料编号"></label><label class="wide-field"><span>政策资格说明</span><input name="policyEligibility" value="${h(profile.policyEligibility || '')}" placeholder="例如:指标生资格已核验"></label></div>`);
}
function onboardingShell(stage, content) {
const passwordDone = stage !== 'password';
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
}
function passwordOnboardingForm() {
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
}
async function renderCandidate(page) {
if (state.user?.role !== 'candidate') return requireLogin();
app.classList.remove('admin-readable');
if (state.user.mustChangePassword) {
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
return;
}
if (!state.profile?.profileCompleted) {
try {
const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
mountAdmissionProfileFields(data.profile);
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); }
return;
}
const meta = {
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'],
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
const data = page === 'notices' ? await api('/api/candidate/notices') : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
state.pageData = data;
if (data.profile) state.profile = data.profile;
const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
} catch (error) { renderError(error); }
}
function candidateDashboard(data) {
const registration = data.registrations[0];
const steps = [
['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'],
['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'],
['考试报名', Boolean(registration), registration ? '已报名' : '未报名'],
['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'],
['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length}` : '待发布']
];
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
}
function candidateProfile(data, onboarding = false) {
const { profile, schools = [], classes = [], workflow } = data;
const step = workflow?.currentStepDetail;
const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
}
function candidateExams(data) {
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>计分规则</dt><dd>总分 ${h(exam.totalScore)} · ${h(passPolicyText(exam))}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)} · 满分 ${h(subject.fullScore)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>满分 0 · ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
}
function candidateRegistrations(registrations) {
if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
const card = reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>应缴金额</dt><dd>${money(reg.amountDue || 0)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}${reg.paidAt ? `<small>${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}</small>` : ''}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}</p>${reg.admitCard && !reg.exam.archivedAt ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`;
const current = registrations.filter(reg => !reg.exam.archivedAt);
const archived = registrations.filter(reg => reg.exam.archivedAt);
return `<div class="registration-cards">${current.map(card).join('')}</div>${archived.length ? `<details class="candidate-archive-fold"><summary><span><strong>历史报名记录</strong><small>${archived.length} 场归档考试 · 点击查阅</small></span><b>${archived.length}</b></summary><div class="registration-cards">${archived.map(card).join('')}</div></details>` : ''}`;
}
function candidateAdmit(registrations) {
const cards = registrations.filter(reg => reg.admitCard);
return cards.length ? `<div class="admit-list">${cards.map(reg => {
const now = Date.now();
const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime();
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const subjectRows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · 考试考场序号 ${h(assignment.examRoomCode || '待定')} · ${h(assignment.roomName || assignment.room || '场地待定')}${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat || '—')}</small></span>`;
}).join('');
const ticket = `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open && !reg.exam.archivedAt ? '' : 'disabled'}>${reg.exam.archivedAt ? '已归档' : open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}</small></div></article>`;
return reg.exam.archivedAt ? `<details class="candidate-archive-fold admit-archive-fold"><summary><span><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档</small></span><b>查看历史准考证</b></summary>${ticket}</details>` : ticket;
}).join('')}</div>` : emptyState('', '', 'candidate/registrations', '');
}
function candidateResults(data) {
const { results, summaries = [] } = data;
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examId) : results.reduce((acc, item) => ((acc[item.examId] ||= []).push(item), acc), {});
const completeSummaries = summaries.filter(item => item.complete);
const overview = `<section class="candidate-result-overview"><article><small>已发布考试</small><strong>${Object.keys(grouped).length}</strong><span>场</span></article><article><small>已发布科目</small><strong>${results.length}</strong><span>科</span></article><article><small>整场已合格</small><strong>${completeSummaries.filter(item => item.qualified === true).length}</strong><span>场</span></article><article><small>复议处理中</small><strong>${results.filter(item => item.appeal?.status === 'pending').length}</strong><span>项</span></article></section>`;
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([, items]) => {
const examName = items[0].examName;
const summary = summaries.find(item => item.examId === items[0].examId);
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `${summary.rank} / ${summary.cohortSize}` : summary ? passPolicyText(summary) : '';
const scores = items.map(item => {
const appeal = item.appeal;
const latestAction = appeal?.actions?.at(-1);
const appealPanel = item.archivedAt
? `<div class="score-appeal-state archived-score-lock">${badge('archived')}<small>本场成绩已永久锁定,复议入口已关闭</small></div>`
: appeal?.status === 'pending'
? `<div class="score-appeal-state">${badge('pending')}<small>${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}</small></div>`
: appeal?.status === 'approved'
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
: `${appeal ? `<div class="score-appeal-state">${badge('rejected')}<small>${h(latestAction?.note || '可补充理由后重新提交')}</small></div>` : ''}<form class="score-appeal-form" data-form="score-appeal"><input type="hidden" name="resultId" value="${h(item.id)}"><textarea name="reason" rows="2" minlength="5" maxlength="500" required placeholder="填写成绩复议理由(至少 5 个字)"></textarea><button class="row-action primary" type="submit">${appeal ? '重新申请复议' : '申请成绩复议'}</button></form>`;
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
}).join('');
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>特征分</small><strong>${h(summary?.featureScore ?? 0)}</strong><i>独立于考试科目</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}</p><div class="result-footer-actions"><strong>已发布 ${items.length} 科</strong><button class="solid-button" data-action="download-score-report" data-exam-id="${h(items[0].examId)}">下载 PDF 成绩单</button></div></footer></section>`;
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
}).join('')}</div>`;
}
function candidateAdmissions(data) {
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' };
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
const notificationCards = (data.notifications || []).map(notification => {
const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus);
return `<article class="admission-notification ${invalid ? 'invalid' : ''}"><div class="admission-notification-mark"><span>ADMISSION</span><strong>${invalid ? '失' : '录'}</strong></div><div class="admission-notification-copy"><header><div><span>${invalid ? '录取状态已更新' : '录取结果已发布'}</span><h2>${h(notification.payload?.title || '录取结果通知')}</h2></div><time>${formatDate(notification.createdAt, true)}</time></header><p>${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}</p><dl><div><dt>录取学校</dt><dd>${h(notification.schoolName || '招生学校')}</dd></div><div><dt>招生类别</dt><dd>${h(notification.categoryName || '以录取通知书为准')}</dd></div><div><dt>所属考试</dt><dd>${h(notification.examName || '—')}</dd></div>${notification.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(notification.noticeNumber)}</dd></div>` : ''}</dl></div><span class="admission-notification-status">${invalid ? '资格已失效' : '正式录取'}</span></article>`;
}).join('');
return `${notificationCards ? `<section class="admission-notification-stack" aria-label="录取结果通知">${notificationCards}</section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false;
const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校';
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
const progressIndex = item.status === 'supplementary' ? 1 : item.status === 'reporting' ? 3 : Math.max(0, progressSteps.indexOf(item.status));
const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
const choiceRow = (choice, preferenceType, index) => {
const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType)));
const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId);
const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode));
const disabled = preferenceType === 'indicator' && !indicatorEligible;
return `<div class="preference-choice-row ${preferenceType}" data-preference-type="${preferenceType}"><b>${preferenceType === 'indicator' ? '指标' : index + 1}</b><label><span>${preferenceType === 'indicator' ? '指标分配志愿学校' : `普通志愿 ${index + 1} · 招生学校`}</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}" ${disabled ? 'disabled' : ''}><option value="">${disabled ? '本场无指标分配资格' : '可不填'}</option>${eligiblePlans.map(entry => `<option value="${h(entry.schoolId)}" ${entry.schoolId === choice.schoolId ? 'selected' : ''}>${h(entry.schoolCode)} · ${h(entry.schoolName)}</option>`).join('')}</select></label><label><span>该校招生类别</span><select name="choiceCategory" ${plan && !disabled ? '' : 'disabled'}><option value="">${plan ? '请选择招生类别' : '请先按代码选择学校'}</option>${categoryOptions.map(category => `<option value="${h(category.code)}" ${category.code === choice.categoryCode ? 'selected' : ''}>${h(category.name)}${category.specialtyCategory ? `${h(specialtyLabel(category.specialtyCategory, category.specialtyType))}` : ''} · 对应余 ${h(preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining)}</option>`).join('')}</select></label></div>`;
};
const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join('');
const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); const schoolCode = choice.schoolCode || plan?.schoolCode || ''; const schoolName = choice.schoolName || plan?.schoolName || choice.schoolId; const categoryName = choice.categoryName || category?.name || choice.categoryCode; return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(schoolName)}</strong><small>${h([schoolCode, categoryName].filter(Boolean).join(' · '))}</small></i></span>`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout ${item.supplementEligible === false ? 'warning' : ''}">${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}</div>`}</section>`;
}).join('')}</div>`;
}
function candidateNotices(notices) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
}
function accountSecurity(totp = {}) {
const account = h(state.user?.candidateNumber || state.user?.username);
const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户';
const password = `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${account}</dd></div><div><dt>账户类型</dt><dd>${type}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
const totpPanel = totp.enabled
? `<section class="panel account-security-panel totp-security-panel enabled"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>TOTP 二次验证已开启</h2><p>登录密码验证通过后,还需要输入验证器应用生成的 6 位动态验证码。</p><div class="totp-signal"><i></i><strong>保护中</strong><span>剩余 ${h(totp.recoveryCodesRemaining)} 个恢复码</span></div></div><div class="totp-security-actions"><details><summary>重新生成恢复码</summary><form class="stack-form account-password-form" data-form="totp-recovery-codes"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="solid-button" type="submit">生成新的恢复码</button></form></details><details class="danger-details"><summary>关闭二次验证</summary><form class="stack-form account-password-form" data-form="totp-disable"><p>关闭后,账户将仅使用密码登录。</p><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="danger-button" type="submit">确认关闭二次验证</button></form></details></div></section>`
: `<section class="panel account-security-panel totp-security-panel"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>添加 TOTP 二次验证</h2><p>使用 Microsoft Authenticator、Google Authenticator、1Password 等验证器应用扫码。即使密码泄露,没有动态验证码也无法登录。</p></div><form class="stack-form account-password-form" data-form="totp-setup"><label><span>确认当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><p class="form-hint">绑定时会显示二维码和手动密钥;验证成功后请立即保存恢复码。</p><button class="solid-button large" type="submit">开始绑定验证器</button></form></section>`;
return `<div class="account-security-stack">${password}${totpPanel}</div>`;
}
return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity };
}
@@ -1,161 +0,0 @@
import { filterTableItems } from './table-state.js';
export function createPublicViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
emptyState
} = context;
function brand() {
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#verify" data-route="verify">文书防伪查询</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
function renderHome() {
app.classList.remove('admin-readable');
const { notices, exams, stats, organization } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0];
app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero">
<div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-route="notice/${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span></span></div><div><strong>${h(stats.registrations || 0)}</strong><span></span></div><div><strong>${h(stats.exams || 0)}</strong><span></span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2></h2></div><p></p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-route="notice/${h(topNotice.id)}"> ${icons.arrow}</button></footer>` : '<p></p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state"></div>'}</div><button class="notice-archive-link" data-route="notices"> ${icons.arrow}</button></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2></h2></div><p></p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state"></div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2></h2></div><p></p></div><div class="flow-track">${[['01','',''],['02','',''],['03','',''],['04','',''],['05','','使']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
}
function noticeDocuments(data = state.publicAnnouncements) {
const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `${item.round} 轮录取名单` : '最终录取名单', title: item.title || `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary }));
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
function publicPaged(items, key, pageSize = 50) {
const filtered = filterTableItems(state, items, key);
state.tablePages ||= {};
const current = state.tablePages[key] || { page: 1, pageSize };
const size = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : pageSize;
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
const page = Math.min(totalPages, Math.max(1, Number(current.page || 1)));
state.tablePages[key] = { page, pageSize: size };
return { items: filtered.slice((page - 1) * size, page * size), total: filtered.length, totalPages, page, pageSize: size, key };
}
function publicPagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button><button type="button" class="active" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page}">${meta.page}</button><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function renderPublicQualification(document) {
const key = `publicQualification-${document.documentId}`;
const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key);
return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button data-action="status-filter" data-target="${h(key)}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${h(key)}" data-status="ineligible">无资格</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${page.items.map(row => `<tr data-status="${h(row.status)}"><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">没有符合条件的资格记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderPublicAdmission(document) {
const key = `publicAdmission-${document.documentId}`;
const page = publicPaged(document.rows, key);
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
return `<p class="document-lead">${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderDocumentBody(document) {
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
if (document.documentType === 'qualification') return renderPublicQualification(document);
if (document.documentType === 'admission') return renderPublicAdmission(document);
if (document.documentType === 'reporting') {
const stats = document.statistics || {};
return `<p class="document-lead">本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。</p><div class="reporting-public-stats"><article><span>招生计划</span><strong>${h(stats.totalQuota || 0)}</strong><small>人</small></article><article><span>正式录取</span><strong>${h(stats.finalCount || 0)}</strong><small>人</small></article><article><span>已报到</span><strong>${h(stats.reportedCount || 0)}</strong><small>人</small></article><article><span>计划完成率</span><strong>${h(stats.reportingRate || 0)}%</strong><small>按实际报到</small></article></div><p class="document-note"><strong>学校说明:</strong>${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}</p>`;
}
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
}
function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') {
app.classList.remove('admin-readable');
const documents = noticeDocuments(data);
const selected = documents.find(item => item.documentId === selectedId);
const organization = state.publicData.organization || {};
if (selected) {
app.innerHTML = `${publicHeader()}<main class="public-main notice-document-page"><div class="notice-breadcrumb"><button data-route="notices">通知公告</button><span>/</span><strong>${h(selected.subtype)}</strong></div><article class="notice-document"><header><span>${h(selected.category)} · ${h(selected.subtype)}</span><h1>${h(selected.title)}</h1><p>${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}</p></header><section>${renderDocumentBody(selected)}</section><footer><button class="ghost-button" data-route="notices">返回通知公告列表</button></footer></article></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>公开信息以本页面正式发布内容为准</span></footer>`;
return;
}
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
const searched = filterTableItems(state, documents, 'publicNoticeDirectory');
const filtered = category === '全部' ? searched : searched.filter(item => item.category === category);
const pageSize = 8;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
state.noticeCategory = category; state.noticePage = page;
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize);
app.innerHTML = `${publicHeader()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="data-toolbar notice-directory-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="publicNoticeDirectory" placeholder="搜索全部通知、公示标题、分类或摘要"></label><button class="row-action" data-action="clear-table-filters" data-target="publicNoticeDirectory">清除搜索</button></div><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}></button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span></span></footer>`;
}
function renderHeroTicket(exam) {
const status = exam.registrationState;
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
}
function renderNoticeRow(notice) {
return `<button class="notice-row" data-route="notice/${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
}
function renderPublicExam(exam) {
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span><span><b>总分</b>${h(exam.totalScore)} 分 · ${h(passPolicyText(exam))}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
}
function renderAuth(kind) {
app.classList.remove('admin-readable');
const login = kind === 'login';
const selfRegistration = state.publicData.selfRegistrationEnabled;
const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
}
function renderVerification(code = '', result = null, error = '') {
app.classList.remove('admin-readable');
const organization = state.publicData.organization || {};
const document = result?.document;
const outcome = document ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div>${document.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(document.noticeNumber)}</dd></div>` : ''}<div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
}
function loginForm() {
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
}
function registerForm() {
const schools = state.publicData.schools || [];
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
}
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
}
@@ -1,42 +0,0 @@
import { chinaRegions } from '../data/china-regions.js';
const option = (value, label, selected = false) => `<option value="${value}" ${selected ? 'selected' : ''}>${label}</option>`;
export function regionSelects(region = {}, { required = true, className = 'region-selects' } = {}) {
const province = chinaRegions.find(item => item.code === region.provinceCode);
const city = province?.cities.find(item => item.code === region.cityCode);
const requiredText = required ? 'required' : '';
return `<div class="${className}" data-region-group>
<label><span> / 自治区 / 直辖市${required ? ' *' : ''}</span><select name="provinceCode" data-region-level="province" ${requiredText}><option value=""></option>${chinaRegions.map(item => option(item.code, item.name, item.code === region.provinceCode)).join('')}</select></label>
<label><span> / 州${required ? ' *' : ''}</span><select name="cityCode" data-region-level="city" ${requiredText}><option value=""></option>${(province?.cities || []).map(item => option(item.code, item.name, item.code === region.cityCode)).join('')}</select></label>
<label><span> / 县${required ? ' *' : ''}</span><select name="districtCode" data-region-level="district" ${requiredText}><option value=""></option>${(city?.districts || []).map(item => option(item.code, item.name, item.code === region.districtCode)).join('')}</select></label>
</div>`;
}
export function updateRegionSelects(select) {
const group = select.closest('[data-region-group]');
if (!group) return;
const provinceSelect = group.querySelector('[name="provinceCode"]');
const citySelect = group.querySelector('[name="cityCode"]');
const districtSelect = group.querySelector('[name="districtCode"]');
const province = chinaRegions.find(item => item.code === provinceSelect?.value);
if (select.dataset.regionLevel === 'province') {
citySelect.innerHTML = `<option value="">请选择城市</option>${(province?.cities || []).map(item => option(item.code, item.name)).join('')}`;
districtSelect.innerHTML = '<option value="">请先选择城市</option>';
} else if (select.dataset.regionLevel === 'city') {
const city = province?.cities.find(item => item.code === citySelect?.value);
districtSelect.innerHTML = `<option value="">请选择区县</option>${(city?.districts || []).map(item => option(item.code, item.name)).join('')}`;
}
}
export function mountRegionSelects(root, region = {}, options = {}) {
const address = root?.querySelector('[name="address"]');
if (!address || root.querySelector('[data-region-group]')) return;
address.closest('label')?.insertAdjacentHTML('beforebegin', regionSelects(region, options));
}
export function formatRegionAddress(region = {}) {
const parts = [region.provinceName, region.cityName, region.districtName]
.filter((value, index, values) => value && value !== values[index - 1]);
return [...parts, region.address].filter(Boolean).join('');
}
-20
View File
@@ -1,20 +0,0 @@
export const state = {
user: null,
profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} },
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
noticeCategory: '全部',
noticePage: 1,
permissions: [],
scopeLabel: '',
authNotice: '',
pageData: null,
resultExamFilter: '',
resultSubjectFilter: '',
resultExamCatalog: null,
resultImportPreview: null,
reportingImportSummaries: {},
tablePages: {},
tableFilters: {},
loading: false
};
@@ -1,44 +0,0 @@
function controlState(state, key) {
state.tableFilters ||= {};
return state.tableFilters[key] ||= { query: '', status: 'all', filters: {} };
}
function searchable(value) {
if (value == null) return '';
if (Array.isArray(value)) return value.map(searchable).join(' ');
if (typeof value === 'object') return Object.values(value).map(searchable).join(' ');
return String(value);
}
function statusTokens(item) {
const tokens = [item?.status, item?.paymentStatus];
if (typeof item?.active === 'boolean') tokens.push(item.active ? 'active approved' : 'inactive disabled closed');
if (typeof item?.published === 'boolean') tokens.push(item.published ? 'published visible' : 'draft hidden');
if (typeof item?.qualified === 'boolean') tokens.push(item.qualified ? 'qualified' : 'unqualified');
if (typeof item?.confirmed === 'boolean') tokens.push(item.confirmed ? (item.eligible ? 'confirmed eligible' : 'confirmed ineligible') : 'unconfirmed');
return tokens.filter(Boolean).join(' ').toLowerCase();
}
export function filterTableItems(state, items, key) {
const control = controlState(state, key);
const query = String(control.query || '').trim().toLocaleLowerCase('zh-CN');
const status = String(control.status || 'all').toLowerCase();
const filters = Object.values(control.filters || {}).filter(Boolean).map(value => String(value).toLocaleLowerCase('zh-CN'));
return (items || []).filter(item => {
const haystack = searchable(item).toLocaleLowerCase('zh-CN');
if (query && !query.split(/\s+/).every(word => haystack.includes(word))) return false;
if (status !== 'all' && !statusTokens(item).split(/\s+/).includes(status)) return false;
return filters.every(value => haystack.includes(value));
});
}
export function setTableControl(state, key, patch) {
const current = controlState(state, key);
Object.assign(current, patch);
if (patch.filters) current.filters = { ...(current.filters || {}), ...patch.filters };
if (state.tablePages?.[key]) state.tablePages[key].page = 1;
}
export function getTableControl(state, key) {
return controlState(state, key);
}
-58
View File
@@ -1,58 +0,0 @@
export const statusLabels = {
pending: '待审核', approved: '已通过', rejected: '需修改',
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员'
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
reporting: '考生报到中', supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', forfeited: '未报到失效', final: '正式录取', unread: '未读', submitted: '已提交', pending_approval: '待审批'
};
export const icons = {
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
};
export function h(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
}
export function passPolicyText(exam) {
const value = Number(exam?.passValue ?? 60);
return {
fixed_score: `总分达到 ${value}`,
score_ratio: `总成绩排名前 ${value}%`,
rank_percent: `总成绩排名前 ${value}%`,
subject_scores: '所有报考科目均达单科线',
none: '仅发布成绩,不判定合格'
}[exam?.passPolicy || 'rank_percent'];
}
export function formatDate(value, withTime = false) {
if (!value) return '待定';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return h(value);
return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
export function dateRange(start, end) {
return `${formatDate(start)}${formatDate(end)}`;
}
export function badge(status) {
return `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
}
export function money(value) {
return `¥${Number(value || 0).toFixed(2)}`;
}
@@ -1,76 +0,0 @@
export const specialtyCatalog = Object.freeze([
Object.freeze({
code: 'sports',
name: '体育',
types: Object.freeze([
Object.freeze({ code: 'track_field', name: '田径' }),
Object.freeze({ code: 'basketball', name: '篮球' }),
Object.freeze({ code: 'football', name: '足球' }),
Object.freeze({ code: 'volleyball', name: '排球' }),
Object.freeze({ code: 'table_tennis', name: '乒乓球' }),
Object.freeze({ code: 'badminton', name: '羽毛球' }),
Object.freeze({ code: 'swimming', name: '游泳' }),
Object.freeze({ code: 'martial_arts', name: '武术' }),
Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' })
])
}),
Object.freeze({
code: 'arts',
name: '艺术',
types: Object.freeze([
Object.freeze({ code: 'vocal_music', name: '声乐' }),
Object.freeze({ code: 'instrumental_music', name: '器乐' }),
Object.freeze({ code: 'dance', name: '舞蹈' }),
Object.freeze({ code: 'fine_arts', name: '美术' }),
Object.freeze({ code: 'calligraphy', name: '书法' }),
Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' })
])
})
]);
const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category]));
const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }])));
const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }])));
export function specialtyCategory(code) {
return categoryMap.get(String(code || '')) || null;
}
export function specialtyType(code) {
return typeMap.get(String(code || '')) || null;
}
export function isValidSpecialty(categoryCode, typeCode) {
if (!categoryCode && !typeCode) return true;
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
return Boolean(category && type && type.categoryCode === category.code);
}
export function resolveProfileSpecialty(profile = {}) {
if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) {
return { category: profile.specialtyCategory, type: profile.specialtyType };
}
const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean);
return legacy || { category: '', type: '' };
}
export function specialtyLabel(categoryCode, typeCode) {
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
if (!category) {
const legacy = legacyTypeMap.get(String(typeCode || ''));
return legacy ? specialtyLabel(legacy.category, legacy.type) : '';
}
return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name;
}
export function candidateEligibleForCategory(profile, category) {
const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null;
const requiredCategory = category?.specialtyCategory || legacy?.category || '';
const requiredType = legacy?.type || category?.specialtyType || '';
if (!requiredCategory) return true;
const qualification = resolveProfileSpecialty(profile);
if (qualification.category !== requiredCategory) return false;
return !requiredType || qualification.type === requiredType;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long