Compare commits
16
Commits
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
# 首次启动前请复制为 .env.docker,并替换下面两个值。
|
# 首次启动前请复制为 .env.docker,并替换下面三个值。
|
||||||
# TOTP_ENCRYPTION_KEY 必须至少 32 个字符,部署后不得随意更换。
|
# 两项密钥必须至少 32 个字符、彼此独立,部署后不得随意更换。
|
||||||
TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
|
||||||
|
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ INITIAL_ADMIN_DISPLAY_NAME=系统管理员
|
|||||||
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
|
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
|
||||||
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
|
||||||
|
# 成绩单与录取通知书防伪码签名密钥。生产环境必须独立设置并长期稳定保存。
|
||||||
|
# DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
|
||||||
|
|
||||||
# 公开首页文案与联系方式(修改后重启应用生效)
|
# 公开首页文案与联系方式(修改后重启应用生效)
|
||||||
PUBLIC_SITE_NAME=海州市教育考试中心
|
PUBLIC_SITE_NAME=海州市教育考试中心
|
||||||
PUBLIC_SITE_CODE=HZ-EDU-032
|
PUBLIC_SITE_CODE=HZ-EDU-032
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
|
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
|
||||||
- 准考证生成状态、开放时间与下载
|
- 准考证生成状态、开放时间与下载
|
||||||
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
||||||
|
- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单
|
||||||
|
- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书
|
||||||
- 通知公告中心
|
- 通知公告中心
|
||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
- 审批实例、当前责任人、转交和监督操作全程留痕
|
- 审批实例、当前责任人、转交和监督操作全程留痕
|
||||||
- 桌面端与移动端响应式布局
|
- 桌面端与移动端响应式布局
|
||||||
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
|
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
|
||||||
|
- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -92,13 +95,15 @@ npm start
|
|||||||
|
|
||||||
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
|
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
|
||||||
|
|
||||||
|
生产环境还必须单独设置至少 32 个字符的 `DOCUMENT_VERIFICATION_SECRET`。系统用它为成绩单和录取通知书生成 HMAC 防伪查询码;更换该值会使此前下载文书的查询码失效,因此应独立生成、稳定保存且不得与 TOTP 密钥共用。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
|
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
|
||||||
|
|
||||||
先创建容器环境文件,并将其中的 TOTP 主密钥和初始管理员密码替换为安全随机值:
|
先创建容器环境文件,并将其中的 TOTP 主密钥、文书防伪签名密钥和初始管理员密码替换为相互独立的安全随机值:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Copy-Item .env.docker.example .env.docker
|
Copy-Item .env.docker.example .env.docker
|
||||||
@@ -159,7 +164,7 @@ git push origin v1.3.0-rc.1
|
|||||||
|
|
||||||
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
|
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
|
||||||
|
|
||||||
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
需要清空并重建空业务库时运行 `npm run reset-db`;该命令与 `npm run initialize-system` 使用同一套初始化流程,会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。也可通过 `npm run reset-db -- --sqlite` 或 `npm run reset-db -- --mysql` 显式选择数据库;MySQL 中存在无法识别为样例数据的业务记录时仍会拒绝覆盖,只有确认目标可清空后才能追加 `--force`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会生成 5 所学校、1200 名批量考生及对应的不同状态报名数据。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
@@ -266,11 +271,14 @@ npm run seed-test-data:mysql -- --force
|
|||||||
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
|
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
|
||||||
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
|
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
|
||||||
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
||||||
7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。
|
7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。
|
||||||
|
8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。
|
||||||
|
9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。
|
||||||
|
10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。
|
||||||
|
|
||||||
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
||||||
|
|
||||||
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。
|
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。
|
||||||
|
|
||||||
数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ import { state } from './src/client/state.mjs';
|
|||||||
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
||||||
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
|
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
|
||||||
import { specialtyCatalog } from './src/data/specialty-types.mjs';
|
import { specialtyCatalog } from './src/data/specialty-types.mjs';
|
||||||
|
import { getTableControl, setTableControl } from './src/client/table-state.mjs';
|
||||||
|
import { downloadAdmissionNotice, downloadScoreReport } from './src/client/pdf-export.mjs';
|
||||||
|
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
const modalRoot = document.querySelector('#modalRoot');
|
const modalRoot = document.querySelector('#modalRoot');
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
let noticeEditor;
|
let noticeEditor;
|
||||||
let ckeditorModulePromise;
|
let ckeditorModulePromise;
|
||||||
|
let tableSearchTimer;
|
||||||
|
let reportingCameraStream;
|
||||||
|
let reportingCameraFrame;
|
||||||
|
let reportingCameraToken = 0;
|
||||||
function toast(title, message = '') {
|
function toast(title, message = '') {
|
||||||
const element = document.querySelector('#toast');
|
const element = document.querySelector('#toast');
|
||||||
element.querySelector('strong').textContent = title;
|
element.querySelector('strong').textContent = title;
|
||||||
@@ -24,17 +30,89 @@ function toast(title, message = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setModal(content) {
|
function setModal(content) {
|
||||||
|
document.body.classList.remove('review-subpage-open');
|
||||||
modalRoot.innerHTML = `<div class="modal-layer" data-modal-backdrop><div class="modal-card" role="dialog" aria-modal="true">${content}</div></div>`;
|
modalRoot.innerHTML = `<div class="modal-layer" data-modal-backdrop><div class="modal-card" role="dialog" aria-modal="true">${content}</div></div>`;
|
||||||
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
|
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setReviewSubpage(content) {
|
||||||
|
document.body.classList.add('review-subpage-open');
|
||||||
|
modalRoot.innerHTML = `<div class="review-subpage-layer"><main class="review-subpage" role="dialog" aria-modal="true">${content}</main></div>`;
|
||||||
|
setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopReportingCamera() {
|
||||||
|
reportingCameraToken += 1;
|
||||||
|
if (reportingCameraFrame) cancelAnimationFrame(reportingCameraFrame);
|
||||||
|
reportingCameraFrame = null;
|
||||||
|
reportingCameraStream?.getTracks().forEach(track => track.stop());
|
||||||
|
reportingCameraStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
stopReportingCamera();
|
||||||
const editor = noticeEditor;
|
const editor = noticeEditor;
|
||||||
noticeEditor = null;
|
noticeEditor = null;
|
||||||
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
|
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
|
||||||
|
document.body.classList.remove('review-subpage-open');
|
||||||
modalRoot.innerHTML = '';
|
modalRoot.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportingScanConfirmation(preview, rawCode) {
|
||||||
|
stopReportingCamera();
|
||||||
|
const row = preview.row;
|
||||||
|
setModal(`<div class="modal-head reporting-confirm-head"><div><span>NOTICE VERIFICATION</span><h2>确认考生报到</h2><p>二维码已通过防伪核验。请核对考生信息,选择结论后暂存。</p></div><button type="button" data-action="close-modal">×</button></div><form class="reporting-confirm-form" data-form="admission-reporting-scan-confirm"><input type="hidden" name="examId" value="${h(preview.examId)}"><input type="hidden" name="code" value="${h(rawCode)}"><section class="reporting-candidate-card"><div class="candidate-stamp">核验通过</div><dl><div><dt>考生姓名</dt><dd>${h(row.name)}</dd></div><div><dt>报名号</dt><dd class="mono">${h(row.candidateNumber)}</dd></div><div><dt>通知书编号</dt><dd class="mono">${h(row.noticeNumber)}</dd></div><div><dt>录取类别</dt><dd>${h(row.categoryName || '—')}</dd></div></dl></section><fieldset class="reporting-decision-options"><legend>报到确认结果</legend><label class="selected"><input type="radio" name="status" value="reported" checked><span><strong>确认报到</strong><small>默认选项,暂存为 Y · 已报到</small></span></label><label><input type="radio" name="status" value="not_reported"><span><strong>确认未报到</strong><small>暂存为 N · 未报到</small></span></label><label><input type="radio" name="status" value="pending"><span><strong>暂待确认</strong><small>暂存为 P · 待确认</small></span></label></fieldset><label class="reporting-confirm-note"><span>报到备注</span><input name="note" value="${h(row.note || '')}" placeholder="选填,系统会为扫码操作生成默认备注"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="open-reporting-camera" data-exam-id="${h(preview.examId)}">重新扫描</button><button type="submit" class="solid-button">暂存</button></div></form>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewReportingScan(code, examId) {
|
||||||
|
const preview = await api('/api/admission/reporting/scan-preview', { method: 'POST', body: { code, examId } });
|
||||||
|
reportingScanConfirmation(preview, code);
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReportingCamera(examId) {
|
||||||
|
stopReportingCamera();
|
||||||
|
const token = reportingCameraToken;
|
||||||
|
setModal(`<div class="modal-head reporting-camera-head"><div><span>LIVE CAMERA</span><h2>扫描录取通知书二维码</h2><p>请将二维码放入取景框,识别后会进入报到确认页,不会直接保存。</p></div><button type="button" data-action="close-modal">×</button></div><section class="reporting-camera-stage"><video data-reporting-camera autoplay muted playsinline></video><div class="scan-frame"><i></i><i></i><i></i><i></i><span>正在等待二维码进入画面</span></div></section><p class="reporting-camera-status" data-reporting-camera-status>正在请求相机权限…</p><div class="reporting-camera-fallback"><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(examId)}"><input name="code" placeholder="无法使用相机时,可粘贴二维码链接或 AN 防伪码" required><button class="ghost-button" type="submit">核验</button></form><label class="qr-capture">选择二维码图片<input type="file" accept="image/*" data-reporting-qr-file data-exam-id="${h(examId)}" hidden></label></div>`);
|
||||||
|
const video = modalRoot.querySelector('[data-reporting-camera]');
|
||||||
|
const status = modalRoot.querySelector('[data-reporting-camera-status]');
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) throw new Error('当前浏览器未提供相机访问能力');
|
||||||
|
reportingCameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false });
|
||||||
|
if (token !== reportingCameraToken) { reportingCameraStream.getTracks().forEach(track => track.stop()); return; }
|
||||||
|
video.srcObject = reportingCameraStream;
|
||||||
|
await video.play();
|
||||||
|
if (!('BarcodeDetector' in window)) throw new Error('相机已打开,但当前浏览器不支持自动识别二维码,请使用下方防伪码核验');
|
||||||
|
const detector = new BarcodeDetector({ formats: ['qr_code'] });
|
||||||
|
status.textContent = '相机已开启,请将二维码对准取景框';
|
||||||
|
let detecting = false;
|
||||||
|
const scan = async () => {
|
||||||
|
if (token !== reportingCameraToken || !reportingCameraStream) return;
|
||||||
|
if (!detecting && video.readyState >= 2) {
|
||||||
|
detecting = true;
|
||||||
|
try {
|
||||||
|
const codes = await detector.detect(video);
|
||||||
|
const code = codes[0]?.rawValue;
|
||||||
|
if (code) {
|
||||||
|
status.textContent = '已识别二维码,正在核验…';
|
||||||
|
await previewReportingScan(code, examId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== reportingCameraToken) return;
|
||||||
|
status.textContent = error.message || '二维码核验失败,请重新对准取景框';
|
||||||
|
} finally { detecting = false; }
|
||||||
|
}
|
||||||
|
reportingCameraFrame = requestAnimationFrame(scan);
|
||||||
|
};
|
||||||
|
scan();
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== reportingCameraToken) return;
|
||||||
|
status.textContent = `无法使用相机:${error.message}。可检查权限,或使用下方备用方式。`;
|
||||||
|
status.classList.add('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadCKEditor() {
|
function loadCKEditor() {
|
||||||
if (!document.querySelector('link[data-ckeditor-styles]')) {
|
if (!document.querySelector('link[data-ckeditor-styles]')) {
|
||||||
const stylesheet = document.createElement('link');
|
const stylesheet = document.createElement('link');
|
||||||
@@ -55,11 +133,27 @@ function emptyState(title, description, route, action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderError(error) {
|
function renderError(error) {
|
||||||
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`;
|
if (error?.status === 401) return requireLogin();
|
||||||
|
console.error('Page failed to render', error);
|
||||||
|
const message = error?.status ? error.message : '请求未能完成,请稍后重试或返回首页。';
|
||||||
|
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(message)}</p><div class="fatal-error-actions"><button class="ghost-button" data-route="home">返回首页</button><button class="solid-button" data-action="retry">重试</button></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
|
function requireLogin() {
|
||||||
const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext);
|
state.user = null;
|
||||||
|
state.profile = null;
|
||||||
|
state.permissions = [];
|
||||||
|
state.scopeLabel = '';
|
||||||
|
state.pageData = null;
|
||||||
|
state.resultExamFilter = '';
|
||||||
|
state.resultSubjectFilter = '';
|
||||||
|
state.resultExamCatalog = null;
|
||||||
|
state.authNotice = '登录状态已失效,请重新登录。';
|
||||||
|
navigate('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState };
|
||||||
|
const { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification } = createPublicViews(baseViewContext);
|
||||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
|
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
|
||||||
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
|
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
|
||||||
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
|
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
|
||||||
@@ -74,7 +168,15 @@ async function renderRoute() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
const route = location.hash.slice(1) || 'home';
|
const route = location.hash.slice(1) || 'home';
|
||||||
const [section, page = 'dashboard'] = route.split('/');
|
const [section, page = 'dashboard'] = route.split('/');
|
||||||
|
if (section !== 'login') state.authNotice = '';
|
||||||
if (section === 'home') renderHome();
|
if (section === 'home') renderHome();
|
||||||
|
else if (section === 'verify') {
|
||||||
|
if (!page || page === 'dashboard') renderVerification();
|
||||||
|
else {
|
||||||
|
try { renderVerification(page, await api(`/api/public/verifications/${encodeURIComponent(page)}`)); }
|
||||||
|
catch (error) { renderVerification(page, null, error.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
|
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
|
||||||
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
|
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
|
||||||
else if (section === 'login' || section === 'register') renderAuth(section);
|
else if (section === 'login' || section === 'register') renderAuth(section);
|
||||||
@@ -82,6 +184,13 @@ async function renderRoute() {
|
|||||||
else if (section === 'admin') await renderAdmin(page);
|
else if (section === 'admin') await renderAdmin(page);
|
||||||
else if (section === 'admission_school') await renderAdmission(page);
|
else if (section === 'admission_school') await renderAdmission(page);
|
||||||
else navigate('home');
|
else navigate('home');
|
||||||
|
restoreTableControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreTableControls() {
|
||||||
|
document.querySelectorAll('[data-action="table-search"][data-target]').forEach(input => { input.value = getTableControl(state, input.dataset.target).query || ''; });
|
||||||
|
document.querySelectorAll('[data-action="status-filter"][data-target]').forEach(button => button.classList.toggle('active', (getTableControl(state, button.dataset.target).status || 'all') === button.dataset.status));
|
||||||
|
document.querySelectorAll('[data-table-filter][data-target]').forEach(select => { select.value = getTableControl(state, select.dataset.target).filters?.[select.dataset.tableFilter] || ''; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formObject(form) {
|
function formObject(form) {
|
||||||
@@ -104,19 +213,67 @@ function updateRegistrationSelection() {
|
|||||||
document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; });
|
document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCandidateSelection() {
|
||||||
|
const table = document.querySelector('#candidateTable');
|
||||||
|
if (!table) return;
|
||||||
|
const selectable = [...table.querySelectorAll('[data-candidate-select]:not(:disabled)')];
|
||||||
|
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||||
|
const selected = selectable.filter(input => input.checked);
|
||||||
|
const selectAll = table.querySelector('[data-candidate-select-all]');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||||
|
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||||
|
}
|
||||||
|
const count = document.querySelector('[data-candidate-selection-count]');
|
||||||
|
if (count) count.textContent = selected.length;
|
||||||
|
document.querySelectorAll('[data-action="bulk-candidate-review"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePaymentSelection() {
|
||||||
|
const table = document.querySelector('#paymentTable');
|
||||||
|
if (!table) return;
|
||||||
|
const selectable = [...table.querySelectorAll('[data-payment-select]:not(:disabled)')];
|
||||||
|
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||||
|
const selected = selectable.filter(input => input.checked);
|
||||||
|
const selectAll = table.querySelector('[data-payment-select-all]');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||||
|
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||||
|
}
|
||||||
|
const count = document.querySelector('[data-payment-selection-count]');
|
||||||
|
if (count) count.textContent = selected.length;
|
||||||
|
document.querySelectorAll('[data-action="bulk-payment-update"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||||
|
}
|
||||||
|
|
||||||
function updateQualificationSelection(container) {
|
function updateQualificationSelection(container) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const rows = [...container.querySelectorAll('[data-qualification-select]')];
|
const rows = [...container.querySelectorAll('[data-qualification-select]')];
|
||||||
|
const visible = rows.filter(input => !input.closest('tr').hidden);
|
||||||
const selected = rows.filter(input => input.checked);
|
const selected = rows.filter(input => input.checked);
|
||||||
const selectAll = container.querySelector('[data-action="qualification-select-all"]');
|
const selectAll = container.querySelector('[data-action="qualification-select-all"]');
|
||||||
if (selectAll) {
|
if (selectAll) {
|
||||||
selectAll.checked = rows.length > 0 && selected.length === rows.length;
|
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||||
selectAll.indeterminate = selected.length > 0 && selected.length < rows.length;
|
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||||
}
|
}
|
||||||
const count = container.querySelector('[data-qualification-selected-count]');
|
const count = container.querySelector('[data-qualification-selected-count]');
|
||||||
if (count) count.textContent = `已选 ${selected.length} 人`;
|
if (count) count.textContent = `已选 ${selected.length} 人`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePlacementSelection() {
|
||||||
|
const table = document.getElementById('placementReviewTable');
|
||||||
|
if (!table) return;
|
||||||
|
const selectable = [...table.querySelectorAll('[data-placement-select]:not(:disabled)')];
|
||||||
|
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||||
|
const selected = selectable.filter(input => input.checked);
|
||||||
|
const selectAll = document.querySelector('[data-placement-select-all][data-target="placementReviewTable"]');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||||
|
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||||
|
}
|
||||||
|
const count = document.querySelector('[data-placement-selected-count]');
|
||||||
|
if (count) count.textContent = `已选 ${selected.length} 人`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyTableFilters(tableId) {
|
function applyTableFilters(tableId) {
|
||||||
const table = document.getElementById(tableId);
|
const table = document.getElementById(tableId);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
@@ -124,13 +281,50 @@ function applyTableFilters(tableId) {
|
|||||||
const query = search?.value.trim().toLowerCase() || '';
|
const query = search?.value.trim().toLowerCase() || '';
|
||||||
const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all';
|
const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all';
|
||||||
const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value);
|
const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value);
|
||||||
table.querySelectorAll('tbody tr[data-filter-row], tbody tr[data-status]').forEach(row => {
|
table.querySelectorAll('[data-filter-row], tbody tr[data-status]').forEach(row => {
|
||||||
const matchesSearch = !query || row.textContent.toLowerCase().includes(query);
|
const matchesSearch = !query || row.textContent.toLowerCase().includes(query);
|
||||||
const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status);
|
const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status);
|
||||||
const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '') === select.value);
|
const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '').split('|').includes(select.value));
|
||||||
row.hidden = !(matchesSearch && matchesStatus && matchesFilters);
|
row.hidden = !(matchesSearch && matchesStatus && matchesFilters);
|
||||||
});
|
});
|
||||||
if (tableId === 'registrationTable') updateRegistrationSelection();
|
if (tableId === 'registrationTable') updateRegistrationSelection();
|
||||||
|
if (tableId === 'candidateTable') updateCandidateSelection();
|
||||||
|
if (tableId === 'paymentTable') updatePaymentSelection();
|
||||||
|
if (tableId === 'placementReviewTable') updatePlacementSelection();
|
||||||
|
if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResultScoreInput(input) {
|
||||||
|
const row = input.closest('tr');
|
||||||
|
const error = row?.querySelector('[data-score-error]');
|
||||||
|
const value = input.value.trim();
|
||||||
|
const score = Number(value);
|
||||||
|
const max = Number(input.max);
|
||||||
|
const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max);
|
||||||
|
input.dataset.dirty = String(value !== input.defaultValue);
|
||||||
|
input.setAttribute('aria-invalid', String(invalid));
|
||||||
|
row?.classList.toggle('score-row-invalid', invalid);
|
||||||
|
if (error) error.textContent = invalid ? `须在 0—${max} 之间` : '';
|
||||||
|
const form = input.closest('form');
|
||||||
|
const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0;
|
||||||
|
const count = form?.querySelector('[data-result-dirty-count]');
|
||||||
|
if (count) count.textContent = dirty ? `${dirty} 条成绩尚未暂存` : '尚无未保存修改';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeatureScoreInput(input) {
|
||||||
|
const row = input.closest('tr');
|
||||||
|
const error = row?.querySelector('[data-feature-score-error]');
|
||||||
|
const value = input.value.trim();
|
||||||
|
const score = Number(value);
|
||||||
|
const invalid = value === '' || !Number.isFinite(score) || score < 0 || score > 1000;
|
||||||
|
input.dataset.dirty = String(value !== input.defaultValue);
|
||||||
|
input.setAttribute('aria-invalid', String(invalid));
|
||||||
|
row?.classList.toggle('score-row-invalid', invalid);
|
||||||
|
if (error) error.textContent = invalid ? '须在 0—1000 之间' : '';
|
||||||
|
const form = input.closest('form');
|
||||||
|
const dirty = form?.querySelectorAll('[data-feature-score][data-dirty="true"]').length || 0;
|
||||||
|
const count = form?.querySelector('[data-feature-dirty-count]');
|
||||||
|
if (count) count.textContent = dirty ? `${dirty} 条特征分尚未保存` : '尚无未保存修改';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPublic() {
|
async function refreshPublic() {
|
||||||
@@ -147,6 +341,7 @@ async function refreshSession() {
|
|||||||
|
|
||||||
async function finishLogin(data) {
|
async function finishLogin(data) {
|
||||||
state.user = data.user;
|
state.user = data.user;
|
||||||
|
state.authNotice = '';
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
closeModal();
|
closeModal();
|
||||||
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
|
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
|
||||||
@@ -192,7 +387,7 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'logout') {
|
if (action === 'logout') {
|
||||||
await api('/api/auth/logout', { method: 'POST' });
|
await api('/api/auth/logout', { method: 'POST' });
|
||||||
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
|
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; state.resultExamFilter = ''; state.resultSubjectFilter = ''; state.resultExamCatalog = null;
|
||||||
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
||||||
}
|
}
|
||||||
if (action === 'open-notice') {
|
if (action === 'open-notice') {
|
||||||
@@ -212,6 +407,20 @@ document.addEventListener('click', async event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
||||||
|
if (action === 'download-score-report') {
|
||||||
|
const examId = target.dataset.examId;
|
||||||
|
const results = (state.pageData?.results || []).filter(item => item.examId === examId);
|
||||||
|
const summary = (state.pageData?.summaries || []).find(item => item.examId === examId);
|
||||||
|
if (!results.length || !summary?.verificationCode) return toast('成绩单暂不可下载', '请刷新页面后重试');
|
||||||
|
await downloadScoreReport({ organization: state.publicData.organization, candidate: state.pageData.candidate || { name: state.user.displayName, candidateNumber: state.user.candidateNumber }, exam: { id: examId, name: results[0].examName, code: results[0].examCode }, results, summary: { ...summary, publishedAt: [...results].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}` });
|
||||||
|
return toast('PDF 成绩单已生成', '文件包含防伪查询码');
|
||||||
|
}
|
||||||
|
if (action === 'download-admission-notice') {
|
||||||
|
const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId);
|
||||||
|
if (!item?.placement || item.placement.status !== 'final' || !item.noticeVerificationCode) return toast('录取通知书暂不可下载', '只有正式录取后才能生成');
|
||||||
|
await downloadAdmissionNotice({ organization: state.publicData.organization, candidate: { name: state.profile?.name || 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}` });
|
||||||
|
return toast('录取通知书已生成', '请核对学校和录取类别');
|
||||||
|
}
|
||||||
if (action === 'download-admitted-candidates') {
|
if (action === 'download-admitted-candidates') {
|
||||||
const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value;
|
const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value;
|
||||||
if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载');
|
if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载');
|
||||||
@@ -222,7 +431,7 @@ document.addEventListener('click', async event => {
|
|||||||
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
|
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
|
||||||
if (reviewNote == null) return;
|
if (reviewNote == null) return;
|
||||||
await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
|
await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
|
||||||
toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute();
|
toast(target.dataset.status === 'approved' ? '招生计划已通过并自动公示' : '招生计划已退回', target.dataset.status === 'approved' ? '公开通知公告已同步生成招生计划公示' : '招生学校可以修改后重新提交'); return renderRoute();
|
||||||
}
|
}
|
||||||
if (action === 'admission-match') {
|
if (action === 'admission-match') {
|
||||||
if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
|
if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
|
||||||
@@ -257,10 +466,78 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'qualification-select-all') {
|
if (action === 'qualification-select-all') {
|
||||||
const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger');
|
const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger');
|
||||||
container?.querySelectorAll('[data-qualification-select]').forEach(input => { input.checked = target.checked; });
|
container?.querySelectorAll('[data-qualification-select]').forEach(input => {
|
||||||
|
if (!input.closest('tr').hidden) input.checked = target.checked;
|
||||||
|
});
|
||||||
updateQualificationSelection(container);
|
updateQualificationSelection(container);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'download-admission-reporting') {
|
||||||
|
window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'open-reporting-camera') {
|
||||||
|
await openReportingCamera(target.dataset.examId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'bulk-reporting-apply') {
|
||||||
|
const toolbar = target.closest('[data-reporting-bulk]');
|
||||||
|
const form = toolbar?.closest('form[data-form="admission-reporting-draft"]');
|
||||||
|
const table = document.getElementById(toolbar?.dataset.tableId || '');
|
||||||
|
const selected = [...(table?.querySelectorAll('[data-reporting-select]:checked') || [])];
|
||||||
|
if (!selected.length) return toast('请先选择考生', '可勾选单人或全选当前页');
|
||||||
|
const status = toolbar.querySelector('[data-reporting-bulk-status]').value;
|
||||||
|
const note = toolbar.querySelector('[data-reporting-bulk-note]').value.trim();
|
||||||
|
selected.forEach(input => {
|
||||||
|
form.querySelector(`[data-reporting-status][data-placement-id="${CSS.escape(input.value)}"]`).value = status;
|
||||||
|
if (note) form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(input.value)}"]`).value = note;
|
||||||
|
});
|
||||||
|
toast('批量修改已应用', `已修改 ${selected.length} 名考生,请点击“暂存当前页”保存`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'submit-admission-reporting') {
|
||||||
|
if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return;
|
||||||
|
await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } });
|
||||||
|
toast('报到情况已提交', '请根据计划完成率确认是否申请补录');
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
|
if (action === 'review-admission-reporting') {
|
||||||
|
const approved = target.dataset.approved === 'true';
|
||||||
|
const supplement = target.dataset.supplement === 'true';
|
||||||
|
const approvalNote = window.prompt(approved ? '请输入审批意见(可选)' : '请输入退回原因', '') ?? '';
|
||||||
|
if (!approved && approvalNote.trim().length < 2) return toast('需要退回原因', '请说明招生学校应修改的内容');
|
||||||
|
let preferenceEnd = '';
|
||||||
|
if (approved && supplement) {
|
||||||
|
preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-30T18:00)', '') ?? '';
|
||||||
|
if (!preferenceEnd) return;
|
||||||
|
}
|
||||||
|
await api(`/api/admin/admission-reporting/${encodeURIComponent(target.dataset.id)}`, { method: 'PATCH', body: { approved, approvalNote, preferenceEnd } });
|
||||||
|
toast(approved ? '审批完成并自动公开' : '已退回招生学校', approved ? (supplement ? '全部学校审批完成后将自动开启补录' : '报到统计已进入公开通知') : '招生学校可修改暂存数据后重新提交');
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
|
if (action === 'admission-ledger-export') {
|
||||||
|
const ledger = target.dataset.ledger;
|
||||||
|
if (!['preferences', 'placements'].includes(ledger)) return;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (target.dataset.examId) query.set('examId', target.dataset.examId);
|
||||||
|
if (target.dataset.round) query.set('round', target.dataset.round);
|
||||||
|
if (target.dataset.table) {
|
||||||
|
const control = getTableControl(state, target.dataset.table);
|
||||||
|
if (String(control.query || '').trim()) query.set('q', String(control.query).trim());
|
||||||
|
if (control.status && control.status !== 'all') query.set('status', control.status);
|
||||||
|
Object.entries(control.filters || {}).forEach(([key, value]) => { if (value) query.set(key, value); });
|
||||||
|
}
|
||||||
|
window.location.href = `/api/admin/admissions/${ledger}/export?${query}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'clear-table-filters') {
|
||||||
|
const tableId = target.dataset.target;
|
||||||
|
state.tableFilters[tableId] = { query: '', status: 'all', filters: {} };
|
||||||
|
document.querySelectorAll(`[data-table-filter][data-target="${tableId}"]`).forEach(select => { select.value = ''; });
|
||||||
|
document.querySelectorAll(`[data-action="table-search"][data-target="${tableId}"]`).forEach(input => { input.value = ''; });
|
||||||
|
document.querySelectorAll(`[data-action="status-filter"][data-target="${tableId}"]`).forEach((button, index) => button.classList.toggle('active', index === 0));
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
if (action === 'bulk-indicator-qualification') {
|
if (action === 'bulk-indicator-qualification') {
|
||||||
const toolbar = target.closest('[data-qualification-bulk]');
|
const toolbar = target.closest('[data-qualification-bulk]');
|
||||||
const ledger = toolbar?.closest('.qualification-ledger');
|
const ledger = toolbar?.closest('.qualification-ledger');
|
||||||
@@ -273,6 +550,20 @@ document.addEventListener('click', async event => {
|
|||||||
toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`);
|
toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`);
|
||||||
return renderRoute();
|
return renderRoute();
|
||||||
}
|
}
|
||||||
|
if (action === 'bulk-placement-review') {
|
||||||
|
const ids = [...document.querySelectorAll('#placementReviewTable [data-placement-select]:checked')].map(input => input.value);
|
||||||
|
const decision = target.dataset.decision;
|
||||||
|
if (!ids.length) return toast('请先选择待审核考生', '可勾选单人,或选择当前筛选结果');
|
||||||
|
let note = '';
|
||||||
|
if (decision === 'withdraw') {
|
||||||
|
note = window.prompt(`将为所选 ${ids.length} 名考生申请退档,请填写统一的特殊理由(至少 8 个字)`, '') ?? '';
|
||||||
|
if (!note) return;
|
||||||
|
if (note.trim().length < 8) return toast('退档理由至少需要 8 个字');
|
||||||
|
} else if (!window.confirm(`确认批量接收所选 ${ids.length} 名投档考生吗?`)) return;
|
||||||
|
const result = await api('/api/admission/placements/bulk', { method: 'POST', body: { ids, decision, note } });
|
||||||
|
toast(decision === 'accept' ? '批量接收完成' : '批量退档申请已提交', `已处理 ${result.count} 名考生`);
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
if (action === 'toggle-admin-account') {
|
if (action === 'toggle-admin-account') {
|
||||||
const active = target.dataset.active === 'true';
|
const active = target.dataset.active === 'true';
|
||||||
if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return;
|
if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return;
|
||||||
@@ -286,6 +577,19 @@ document.addEventListener('click', async event => {
|
|||||||
setModal(`<div class="modal-head"><div><span>ADMIN PASSWORD RESET</span><h2>管理员临时密码已生成</h2><p>请通过线下安全渠道交给管理员,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>旧密码和现有登录会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
|
setModal(`<div class="modal-head"><div><span>ADMIN PASSWORD RESET</span><h2>管理员临时密码已生成</h2><p>请通过线下安全渠道交给管理员,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>旧密码和现有登录会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'toggle-admission-account') {
|
||||||
|
const active = target.dataset.active === 'true';
|
||||||
|
if (!active && !window.confirm('确认停用该招生学校账户?现有登录会话会立即失效,历史审核记录继续保留。')) return;
|
||||||
|
await api(`/api/admin/admission-school-accounts/${target.dataset.id}`, { method: 'PATCH', body: { active } });
|
||||||
|
toast(active ? '招生学校账户已启用' : '招生学校账户已停用');
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
|
if (action === 'reset-admission-account-password') {
|
||||||
|
if (!window.confirm('确认重置该招生学校账户密码?旧密码和现有登录会话会立即失效。')) return;
|
||||||
|
const data = await api(`/api/admin/admission-school-accounts/${target.dataset.id}/reset-password`, { method: 'POST' });
|
||||||
|
setModal(`<div class="modal-head"><div><span>ADMISSION ACCOUNT RESET</span><h2>招生学校临时密码已生成</h2><p>请通过线下安全渠道交给招生学校,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>账户已同步启用,旧密码和现有会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'add-admission-category') {
|
if (action === 'add-admission-category') {
|
||||||
const sources = state.pageData?.sourceSchools || [];
|
const sources = state.pageData?.sourceSchools || [];
|
||||||
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
|
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
|
||||||
@@ -315,6 +619,7 @@ document.addEventListener('click', async event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'new-notice') { await openNoticeForm(); return; }
|
if (action === 'new-notice') { await openNoticeForm(); return; }
|
||||||
|
if (action === 'edit-notice') { await openNoticeForm(state.pageData.notices.find(item => item.id === target.dataset.id)); return; }
|
||||||
if (action === 'new-exam') return openExamForm();
|
if (action === 'new-exam') return openExamForm();
|
||||||
if (action === 'new-admin') return openAdminForm();
|
if (action === 'new-admin') return openAdminForm();
|
||||||
if (action === 'new-school') return openSchoolForm();
|
if (action === 'new-school') return openSchoolForm();
|
||||||
@@ -405,6 +710,24 @@ document.addEventListener('click', async event => {
|
|||||||
toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新');
|
toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新');
|
||||||
return renderRoute();
|
return renderRoute();
|
||||||
}
|
}
|
||||||
|
if (action === 'bulk-candidate-review') {
|
||||||
|
const ids = [...document.querySelectorAll('#candidateTable [data-candidate-select]:checked')].map(input => input.value);
|
||||||
|
if (!ids.length) return toast('请先选择考生', '仅当前步骤可由你处理的资料可以勾选');
|
||||||
|
const status = target.dataset.status;
|
||||||
|
const reviewNote = window.prompt(status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)', '');
|
||||||
|
if (reviewNote === null) return;
|
||||||
|
if (status === 'rejected' && !reviewNote.trim()) return toast('请填写退回原因', '考生需要根据原因补充或修改资料');
|
||||||
|
let completed = 0;
|
||||||
|
const failures = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/candidates/${id}`, { method: 'PATCH', body: { status, reviewNote } });
|
||||||
|
completed += 1;
|
||||||
|
} catch (error) { failures.push(error.message); }
|
||||||
|
}
|
||||||
|
toast(status === 'rejected' ? `已退回 ${completed} 名考生资料` : `已处理 ${completed} 名考生资料`, failures.length ? `${failures.length} 名未完成,请检查其流程责任人` : '资料状态与流程轨迹已同步更新');
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
if (action === 'confirm-payment') {
|
if (action === 'confirm-payment') {
|
||||||
if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return;
|
if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return;
|
||||||
await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' });
|
await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' });
|
||||||
@@ -421,11 +744,31 @@ document.addEventListener('click', async event => {
|
|||||||
toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除');
|
toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除');
|
||||||
return renderRoute();
|
return renderRoute();
|
||||||
}
|
}
|
||||||
|
if (action === 'bulk-payment-update') {
|
||||||
|
const selected = [...document.querySelectorAll('#paymentTable [data-payment-select]:checked')];
|
||||||
|
if (!selected.length) return toast('请先选择缴费记录');
|
||||||
|
const status = target.dataset.status;
|
||||||
|
const changed = selected.filter(input => input.dataset.status !== status);
|
||||||
|
const skipped = selected.length - changed.length;
|
||||||
|
if (!changed.length) return toast('所选记录无需更改', status === 'paid' ? '所选考生都已缴费' : '所选考生都处于待缴费状态');
|
||||||
|
if (!window.confirm(`确认将 ${changed.length} 条缴费记录批量改为“${status === 'paid' ? '已缴费' : '待缴费'}”吗?`)) return;
|
||||||
|
let completed = 0;
|
||||||
|
const failures = [];
|
||||||
|
for (const input of changed) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/payments/${input.value}`, { method: 'PATCH', body: { status } });
|
||||||
|
completed += 1;
|
||||||
|
} catch (error) { failures.push(error.message); }
|
||||||
|
}
|
||||||
|
toast(`已更新 ${completed} 条缴费记录`, [skipped ? `${skipped} 条状态相同已跳过` : '', failures.length ? `${failures.length} 条未完成` : ''].filter(Boolean).join(';'));
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
if (action === 'excel-download') {
|
if (action === 'excel-download') {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (target.dataset.template === '1') query.set('template', '1');
|
if (target.dataset.template === '1') query.set('template', '1');
|
||||||
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
|
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
|
||||||
if (target.dataset.examId) query.set('examId', target.dataset.examId);
|
if (target.dataset.examId) query.set('examId', target.dataset.examId);
|
||||||
|
else if (target.dataset.resource === 'results' && state.resultExamFilter) query.set('examId', state.resultExamFilter);
|
||||||
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
|
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -435,6 +778,16 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'result-exam-filter') {
|
if (action === 'result-exam-filter') {
|
||||||
state.resultExamFilter = target.dataset.id;
|
state.resultExamFilter = target.dataset.id;
|
||||||
|
state.resultSubjectFilter = '';
|
||||||
|
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
|
||||||
|
if (state.tablePages[key]) state.tablePages[key].page = 1;
|
||||||
|
});
|
||||||
|
return renderRoute();
|
||||||
|
}
|
||||||
|
if (action === 'table-page') {
|
||||||
|
const key = target.dataset.tableKey;
|
||||||
|
const current = state.tablePages[key] || { page: 1, pageSize: 50 };
|
||||||
|
state.tablePages[key] = { ...current, page: Math.max(1, Number(target.dataset.page) || 1) };
|
||||||
return renderRoute();
|
return renderRoute();
|
||||||
}
|
}
|
||||||
if (action === 'cancel-result-import') {
|
if (action === 'cancel-result-import') {
|
||||||
@@ -481,19 +834,37 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'toggle-notice') {
|
if (action === 'toggle-notice') {
|
||||||
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
||||||
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
|
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已隐藏', '公开展示状态已更新'); return refreshPublic().then(renderRoute);
|
||||||
|
}
|
||||||
|
if (action === 'toggle-publication') {
|
||||||
|
const visible = target.dataset.visible === 'true';
|
||||||
|
await api(`/api/admin/publications/${target.dataset.sourceType}/${target.dataset.id}`, { method: 'PATCH', body: { visible } });
|
||||||
|
toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); return refreshPublic().then(renderRoute);
|
||||||
}
|
}
|
||||||
if (action === 'status-filter') {
|
if (action === 'status-filter') {
|
||||||
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
|
setTableControl(state, target.dataset.target, { status: target.dataset.status });
|
||||||
applyTableFilters(target.dataset.target);
|
return renderRoute();
|
||||||
}
|
}
|
||||||
} catch (error) { toast('操作未完成', error.message); }
|
} catch (error) { toast('操作未完成', error.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('input', event => {
|
document.addEventListener('input', event => {
|
||||||
if (event.target.matches('[data-action="table-search"]')) {
|
if (event.target.matches('[data-action="table-search"]')) {
|
||||||
|
setTableControl(state, event.target.dataset.target, { query: event.target.value });
|
||||||
applyTableFilters(event.target.dataset.target);
|
applyTableFilters(event.target.dataset.target);
|
||||||
|
clearTimeout(tableSearchTimer);
|
||||||
|
tableSearchTimer = setTimeout(() => renderRoute(), 260);
|
||||||
}
|
}
|
||||||
|
if (event.target.closest('[data-notice-template]')) {
|
||||||
|
const studio = event.target.closest('[data-notice-template]');
|
||||||
|
const preview = studio.querySelector('.notice-template-preview');
|
||||||
|
if (event.target.name === 'primaryColor') preview.style.setProperty('--template-primary', event.target.value);
|
||||||
|
if (event.target.name === 'accentColor') preview.style.setProperty('--template-accent', event.target.value);
|
||||||
|
const output = studio.querySelector(`[data-template-preview="${event.target.name}"]`);
|
||||||
|
if (output) output.textContent = event.target.value.replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}', state.pageData?.school?.name || '本校').replaceAll('{{录取类别}}','普通生');
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-result-score]')) updateResultScoreInput(event.target);
|
||||||
|
if (event.target.matches('[data-feature-score]')) updateFeatureScoreInput(event.target);
|
||||||
if (event.target.matches('.subject-options input')) {
|
if (event.target.matches('.subject-options input')) {
|
||||||
const form = event.target.closest('form');
|
const form = event.target.closest('form');
|
||||||
const checked = [...form.querySelectorAll('.subject-options input:checked')];
|
const checked = [...form.querySelectorAll('.subject-options input:checked')];
|
||||||
@@ -507,9 +878,60 @@ document.addEventListener('input', event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('change', event => {
|
document.addEventListener('change', event => {
|
||||||
|
if (event.target.matches('[data-action="table-page-size"]')) {
|
||||||
|
const key = event.target.dataset.tableKey;
|
||||||
|
state.tablePages[key] = { page: 1, pageSize: Number(event.target.value) || 50 };
|
||||||
|
renderRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-placement-select]')) updatePlacementSelection();
|
||||||
|
if (event.target.matches('[data-placement-select-all]')) {
|
||||||
|
const table = document.getElementById(event.target.dataset.target);
|
||||||
|
table?.querySelectorAll('[data-placement-select]:not(:disabled)').forEach(input => {
|
||||||
|
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||||
|
});
|
||||||
|
updatePlacementSelection();
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-reporting-select]')) {
|
||||||
|
const toolbar = event.target.closest('form')?.querySelector('[data-reporting-bulk]');
|
||||||
|
const table = event.target.closest('table');
|
||||||
|
const selected = table ? table.querySelectorAll('[data-reporting-select]:checked').length : 0;
|
||||||
|
const total = table ? table.querySelectorAll('[data-reporting-select]').length : 0;
|
||||||
|
const count = toolbar?.querySelector('[data-reporting-selected-count]');
|
||||||
|
const selectAll = toolbar?.querySelector('[data-reporting-select-all]');
|
||||||
|
if (count) count.textContent = `已选 ${selected} 人`;
|
||||||
|
if (selectAll) { selectAll.checked = total > 0 && selected === total; selectAll.indeterminate = selected > 0 && selected < total; }
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-reporting-select-all]')) {
|
||||||
|
const table = document.getElementById(event.target.dataset.tableId);
|
||||||
|
table?.querySelectorAll('[data-reporting-select]').forEach(input => { input.checked = event.target.checked; });
|
||||||
|
const count = event.target.closest('[data-reporting-bulk]')?.querySelector('[data-reporting-selected-count]');
|
||||||
|
if (count) count.textContent = `已选 ${event.target.checked ? table?.querySelectorAll('[data-reporting-select]').length || 0 : 0} 人`;
|
||||||
|
}
|
||||||
|
if (event.target.matches('.reporting-decision-options input[type="radio"]')) {
|
||||||
|
event.target.closest('fieldset').querySelectorAll('label').forEach(label => label.classList.toggle('selected', label.contains(event.target)));
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-action="result-bulk-exam"]')) {
|
||||||
|
state.resultExamFilter = event.target.value;
|
||||||
|
state.resultSubjectFilter = '';
|
||||||
|
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
|
||||||
|
if (state.tablePages[key]) state.tablePages[key].page = 1;
|
||||||
|
});
|
||||||
|
renderRoute();
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-action="result-bulk-subject"]')) {
|
||||||
|
state.resultSubjectFilter = event.target.value;
|
||||||
|
if (state.tablePages.resultEntryTable) state.tablePages.resultEntryTable.page = 1;
|
||||||
|
renderRoute();
|
||||||
|
}
|
||||||
if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger'));
|
if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger'));
|
||||||
if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target);
|
if (event.target.matches('[data-table-filter]')) {
|
||||||
|
setTableControl(state, event.target.dataset.target, { filters: { [event.target.dataset.tableFilter]: event.target.value } });
|
||||||
|
renderRoute();
|
||||||
|
}
|
||||||
if (event.target.matches('[data-registration-select]')) updateRegistrationSelection();
|
if (event.target.matches('[data-registration-select]')) updateRegistrationSelection();
|
||||||
|
if (event.target.matches('[data-candidate-select]')) updateCandidateSelection();
|
||||||
|
if (event.target.matches('[data-payment-select]')) updatePaymentSelection();
|
||||||
if (event.target.matches('[data-registration-select-all]')) {
|
if (event.target.matches('[data-registration-select-all]')) {
|
||||||
const table = document.getElementById(event.target.dataset.target);
|
const table = document.getElementById(event.target.dataset.target);
|
||||||
table?.querySelectorAll('[data-registration-select]:not(:disabled)').forEach(input => {
|
table?.querySelectorAll('[data-registration-select]:not(:disabled)').forEach(input => {
|
||||||
@@ -517,6 +939,20 @@ document.addEventListener('change', event => {
|
|||||||
});
|
});
|
||||||
updateRegistrationSelection();
|
updateRegistrationSelection();
|
||||||
}
|
}
|
||||||
|
if (event.target.matches('[data-candidate-select-all]')) {
|
||||||
|
const table = document.getElementById(event.target.dataset.target);
|
||||||
|
table?.querySelectorAll('[data-candidate-select]:not(:disabled)').forEach(input => {
|
||||||
|
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||||
|
});
|
||||||
|
updateCandidateSelection();
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-payment-select-all]')) {
|
||||||
|
const table = document.getElementById(event.target.dataset.target);
|
||||||
|
table?.querySelectorAll('[data-payment-select]:not(:disabled)').forEach(input => {
|
||||||
|
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||||
|
});
|
||||||
|
updatePaymentSelection();
|
||||||
|
}
|
||||||
if (event.target.matches('[data-excel-file]')) {
|
if (event.target.matches('[data-excel-file]')) {
|
||||||
const input = event.target;
|
const input = event.target;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -635,7 +1071,7 @@ document.addEventListener('submit', async event => {
|
|||||||
const form = event.target.closest('form[data-form]');
|
const form = event.target.closest('form[data-form]');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const submit = form.querySelector('button[type="submit"]');
|
const submit = event.submitter || form.querySelector('button[type="submit"]');
|
||||||
const original = submit?.innerHTML;
|
const original = submit?.innerHTML;
|
||||||
if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; }
|
if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; }
|
||||||
try {
|
try {
|
||||||
@@ -651,6 +1087,10 @@ document.addEventListener('submit', async event => {
|
|||||||
} else if (kind === 'register') {
|
} else if (kind === 'register') {
|
||||||
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
|
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
|
||||||
setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
|
setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
|
||||||
|
} else if (kind === 'document-verification') {
|
||||||
|
const code = form.code.value.trim().toUpperCase();
|
||||||
|
if (!code) throw new Error('请输入防伪查询码');
|
||||||
|
navigate(`verify/${encodeURIComponent(code)}`);
|
||||||
} else if (kind === 'candidate-password') {
|
} else if (kind === 'candidate-password') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
|
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
|
||||||
@@ -715,6 +1155,32 @@ document.addEventListener('submit', async event => {
|
|||||||
} else if (kind === 'admission-account') {
|
} else if (kind === 'admission-account') {
|
||||||
await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
|
await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
|
||||||
form.reset(); toast('招生学校账号已创建'); renderRoute();
|
form.reset(); toast('招生学校账号已创建'); renderRoute();
|
||||||
|
} else if (kind === 'admission-notice-template') {
|
||||||
|
await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) });
|
||||||
|
toast('录取通知书模板已保存', '正式录取考生将使用该模板生成 PDF'); renderRoute();
|
||||||
|
} else if (kind === 'admission-reporting-draft') {
|
||||||
|
const rows = [...form.querySelectorAll('[data-reporting-status]')].map(select => ({
|
||||||
|
placementId: select.dataset.placementId,
|
||||||
|
status: select.value,
|
||||||
|
note: form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(select.dataset.placementId)}"]`)?.value || ''
|
||||||
|
}));
|
||||||
|
if (!rows.length) return toast('当前页没有可暂存记录');
|
||||||
|
await api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: form.dataset.examId, rows } });
|
||||||
|
toast('当前页已暂存', `${rows.length} 名考生的状态已保存,尚未正式提交`); renderRoute();
|
||||||
|
} else if (kind === 'admission-reporting-scan-preview') {
|
||||||
|
const body = formObject(form);
|
||||||
|
await previewReportingScan(body.code, body.examId);
|
||||||
|
} else if (kind === 'admission-reporting-scan-confirm') {
|
||||||
|
const body = formObject(form);
|
||||||
|
const result = await api('/api/admission/reporting/scan', { method: 'POST', body });
|
||||||
|
closeModal();
|
||||||
|
const label = body.status === 'reported' ? '已报到' : body.status === 'not_reported' ? '未报到' : '待确认';
|
||||||
|
toast('扫码结果已暂存', `${result.row.name} · ${label},尚未正式提交`); renderRoute();
|
||||||
|
} else if (kind === 'admission-reporting-decision') {
|
||||||
|
const body = formObject(form);
|
||||||
|
body.supplement = body.supplement === 'true';
|
||||||
|
await api('/api/admission/reporting/decision', { method: 'POST', body });
|
||||||
|
toast('学校决定已提交', '等待超级管理员审批;审批后报到统计将自动公开'); renderRoute();
|
||||||
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
|
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
|
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
|
||||||
@@ -813,8 +1279,9 @@ document.addEventListener('submit', async event => {
|
|||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
if (noticeEditor) body.content = noticeEditor.getData();
|
if (noticeEditor) body.content = noticeEditor.getData();
|
||||||
body.pinned = form.pinned.checked;
|
body.pinned = form.pinned.checked;
|
||||||
await api('/api/admin/notices', { method: 'POST', body });
|
const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices';
|
||||||
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
|
await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body });
|
||||||
|
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); renderRoute();
|
||||||
} else if (kind === 'exam-form') {
|
} else if (kind === 'exam-form') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
||||||
@@ -836,10 +1303,42 @@ document.addEventListener('submit', async event => {
|
|||||||
const body = formObject(form); body.published = form.published.checked;
|
const body = formObject(form); body.published = form.published.checked;
|
||||||
await api('/api/admin/results', { method: 'POST', body });
|
await api('/api/admin/results', { method: 'POST', body });
|
||||||
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
|
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
|
||||||
|
} else if (kind === 'result-bulk-entry') {
|
||||||
|
const mode = event.submitter?.dataset.resultMode || 'draft';
|
||||||
|
const inputs = [...form.querySelectorAll('[data-result-score]')];
|
||||||
|
const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max)));
|
||||||
|
if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); }
|
||||||
|
const targets = mode === 'publish'
|
||||||
|
? inputs.filter(input => input.value.trim() !== '')
|
||||||
|
: inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== '');
|
||||||
|
if (mode === 'publish') {
|
||||||
|
const missing = inputs.filter(input => input.value.trim() === '');
|
||||||
|
if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); }
|
||||||
|
if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
|
||||||
|
}
|
||||||
|
if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩');
|
||||||
|
const body = {
|
||||||
|
examId: form.examId.value,
|
||||||
|
subjectId: form.subjectId.value,
|
||||||
|
published: mode === 'publish',
|
||||||
|
rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) }))
|
||||||
|
};
|
||||||
|
const result = await api('/api/admin/results/bulk', { method: 'POST', body });
|
||||||
|
toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`);
|
||||||
|
renderRoute();
|
||||||
} else if (kind === 'feature-score-entry') {
|
} else if (kind === 'feature-score-entry') {
|
||||||
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
|
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
|
||||||
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
|
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
|
||||||
toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute();
|
toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute();
|
||||||
|
} else if (kind === 'feature-score-bulk') {
|
||||||
|
const inputs = [...form.querySelectorAll('[data-feature-score]')];
|
||||||
|
const invalid = inputs.find(input => input.value.trim() === '' || !Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > 1000);
|
||||||
|
if (invalid) { invalid.focus(); throw new Error('特征分必须在 0—1000 之间;未参加时填写 0'); }
|
||||||
|
const targets = inputs.filter(input => input.dataset.dirty === 'true');
|
||||||
|
if (!targets.length) throw new Error('请先修改至少一名考生的特征分');
|
||||||
|
const result = await api('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: form.examId.value, rows: targets.map(input => ({ registrationId: input.dataset.registrationId, featureScore: Number(input.value) })) } });
|
||||||
|
toast('特征分已保存', `${result.count} 名考生已更新;其余考生继续保持 0 分`);
|
||||||
|
renderRoute();
|
||||||
} else if (kind === 'result-import-commit') {
|
} else if (kind === 'result-import-commit') {
|
||||||
const rows = state.resultImportPreview?.rows || [];
|
const rows = state.resultImportPreview?.rows || [];
|
||||||
if (!rows.length) throw new Error('没有可提交的成绩预览');
|
if (!rows.length) throw new Error('没有可提交的成绩预览');
|
||||||
@@ -919,8 +1418,11 @@ function openFlowDetail(id) {
|
|||||||
function openCandidateReview(id) {
|
function openCandidateReview(id) {
|
||||||
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
||||||
const item = { ...source, address: formatRegionAddress(source) };
|
const item = { ...source, address: formatRegionAddress(source) };
|
||||||
const canReview = item.status === 'pending' && item.workflow?.assignee?.id === state.user.id;
|
const canReview = item.status === 'pending' && (item.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
|
||||||
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
const registrations = item.registrations || [];
|
||||||
|
const examCards = registrations.map(registration => `<article class="review-exam-card"><header><div><span class="exam-code">${h(registration.exam?.code || '')}</span><h3>${h(registration.exam?.name || '考试信息异常')}</h3></div>${badge(registration.exam?.archivedAt ? 'archived' : registration.status)}</header><dl><div><dt>考试时间</dt><dd>${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(registration.exam?.location || '待公布')}</dd></div><div><dt>报名科目</dt><dd><div class="review-subject-list">${registration.subjects?.map(subject => `<span><strong>${h(subject.name)}</strong><small>${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}</small></span>`).join('') || '<em>未选择科目</em>'}</div></dd></div><div><dt>报名 / 缴费</dt><dd>${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}</dd></div></dl></article>`).join('');
|
||||||
|
const decision = canReview ? `<form class="review-decision-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><div><span>当前审核步骤</span><strong>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(item.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写核验说明;退回时请明确指出需要补充或修改的资料">${h(item.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${item.status === 'pending' ? `本步骤由 ${h(item.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `资料状态:${h(statusLabels[item.status] || item.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
|
||||||
|
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回考生资料审核</button><div><span>CANDIDATE DOSSIER</span><h1>${h(item.name)} · 资料审核</h1><p><b class="mono">${h(item.candidateNumber)}</b><i></i>${h(item.school || '学校未填写')} · ${h(item.grade || '班级未填写')}<i></i>更新于 ${formatDate(item.updatedAt, true)}</p></div>${badge(item.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section"><header><span>01</span><div><h2>身份与学籍信息</h2><p>核对实名、学籍范围以及联系方式。</p></div></header><dl class="review-detail-grid"><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 出生日期</dt><dd>${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}</dd></div><div><dt>籍贯 / 民族</dt><dd>${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}</dd></div><div><dt>就读学校 / 班级</dt><dd>${h(item.school || '未填写')} · ${h(item.grade || '未填写')}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone || '未填写')}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email || '未填写')}</dd></div><div class="wide"><dt>家庭住址</dt><dd>${h(item.address || '未填写')}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}</dd></div><div><dt>紧急联系人</dt><dd>${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}</dd></div></dl></section><section class="review-section"><header><span>02</span><div><h2>关联考试与报名科目</h2><p>审核资料时同时查看该考生历次报名上下文。</p></div><strong>${registrations.length} 场</strong></header><div class="review-exam-list">${examCards || '<div class="review-empty-context"><strong>暂无考试报名</strong><p>该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。</p></div>'}</div></section></div>${decision}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCandidatePasswordReset(id) {
|
function openCandidatePasswordReset(id) {
|
||||||
@@ -932,11 +1434,18 @@ function openCandidatePasswordReset(id) {
|
|||||||
function openRegistrationReview(id) {
|
function openRegistrationReview(id) {
|
||||||
const reg = state.pageData.registrations.find(item => item.id === id);
|
const reg = state.pageData.registrations.find(item => item.id === id);
|
||||||
const canReview = reg.status === 'pending' && (reg.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
|
const canReview = reg.status === 'pending' && (reg.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
|
||||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
const subjects = reg.subjects || [];
|
||||||
|
const subjectCards = subjects.map((subject, index) => `<article><i>${index + 1}</i><div><strong>${h(subject.name)}</strong><span>${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `—${h(subject.end)}` : ''}</span></div><dl><div><dt>满分</dt><dd>${h(subject.fullScore)} 分</dd></div><div><dt>报名费</dt><dd>${money(subject.fee || 0)}</dd></div></dl></article>`).join('');
|
||||||
|
const decision = canReview ? `<form class="review-decision-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><div><span>当前审核步骤</span><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(reg.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回本次报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写审核依据;退回时请说明考试或科目问题">${h(reg.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${reg.status === 'pending' ? `本步骤由 ${h(reg.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `报名状态:${h(statusLabels[reg.status] || reg.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
|
||||||
|
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回报名审核</button><div><span>REGISTRATION DOSSIER</span><h1>${h(reg.candidate?.name)} · 报名审核</h1><p><b class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}</b><i></i>${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</p></div>${badge(reg.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section exam-context-hero"><header><span>考试</span><div><p class="exam-code">${h(reg.exam.code)}</p><h2>${h(reg.exam.name)}</h2></div></header><dl class="review-detail-grid"><div><dt>报名时间</dt><dd>${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(reg.exam.examStart, reg.exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(reg.exam.location || '待公布')}</dd></div><div><dt>本次报名</dt><dd><strong>${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}</strong></dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}</dd></div></dl></section><section class="review-section subject-review-section"><header><span>科目</span><div><h2>本次所报科目</h2><p>逐科核对日期、时间、满分和报名费用。</p></div><strong>${subjects.length} 科</strong></header><div class="review-subject-cards">${subjectCards || '<div class="review-empty-context"><strong>未选择任何科目</strong><p>该报名记录数据异常,不应通过审核。</p></div>'}</div><footer><span>费用合计</span><strong>${money(reg.amountDue || 0)}</strong></footer></section><section class="review-section"><header><span>考生</span><div><h2>考生资料摘要</h2><p>报名审核同时核验身份与学校范围。</p></div></header><dl class="review-detail-grid"><div><dt>姓名 / 性别</dt><dd>${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}</dd></div><div><dt>证件号码</dt><dd class="mono">${h(reg.candidate?.idNumber || '未填写')}</dd></div><div><dt>学校 / 班级</dt><dd>${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</dd></div><div><dt>联系电话</dt><dd>${h(reg.candidate?.phone || '未填写')}</dd></div></dl></section></div>${decision}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNoticeForm() {
|
async function openNoticeForm(notice = null) {
|
||||||
setModal(`<div class="modal-head"><div><span>NEW NOTICE</span><h2>发布通知公告</h2><p>发布后将立即显示在公开首页和考生中心。</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form"><div class="field-row"><label><span>通知分类</span><select name="category"><option>报名通知</option><option>考试须知</option><option>考点公告</option><option>成绩通知</option><option>系统公告</option></select></label><label><span>发布方式</span><select name="status"><option value="published">立即发布</option><option value="draft">保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容"></textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned"><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">保存通知</button></div></form>`);
|
if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试');
|
||||||
|
const editing = Boolean(notice);
|
||||||
|
const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告'];
|
||||||
|
if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category);
|
||||||
|
setModal(`<div class="modal-head"><div><span>${editing ? 'EDIT DRAFT' : 'NEW NOTICE'}</span><h2>${editing ? '编辑通知草稿' : '发布通知公告'}</h2><p>${editing ? '修改后可继续保存草稿,也可直接发布。' : '发布后将立即显示在公开首页和考生中心。'}</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form">${editing ? `<input type="hidden" name="id" value="${h(notice.id)}">` : ''}<div class="field-row"><label><span>通知分类</span><select name="category">${categories.map(category => `<option ${category === notice?.category ? 'selected' : ''}>${h(category)}</option>`).join('')}</select></label><label><span>发布方式</span><select name="status"><option value="published" ${notice?.status === 'published' ? 'selected' : ''}>立即发布</option><option value="draft" ${!notice || notice.status === 'draft' ? 'selected' : ''}>保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项" value="${h(notice?.title || '')}"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取" value="${h(notice?.summary || '')}"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容">${h(notice?.content || '')}</textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned" ${notice?.pinned ? 'checked' : ''}><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存修改' : '保存通知'}</button></div></form>`);
|
||||||
const source = modalRoot.querySelector('[data-notice-editor]');
|
const source = modalRoot.querySelector('[data-notice-editor]');
|
||||||
try {
|
try {
|
||||||
const [ckeditor, translation] = await loadCKEditor();
|
const [ckeditor, translation] = await loadCKEditor();
|
||||||
@@ -1071,7 +1580,51 @@ function openAdmitPreview(reg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', renderRoute);
|
window.addEventListener('hashchange', renderRoute);
|
||||||
window.addEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); });
|
window.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Escape') closeModal();
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||||
|
const form = document.querySelector('form[data-form="result-bulk-entry"]');
|
||||||
|
if (form) {
|
||||||
|
event.preventDefault();
|
||||||
|
form.requestSubmit(form.querySelector('[data-result-mode="draft"]'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', event => {
|
||||||
|
if (event.target.matches('[data-admission-reporting-file]')) {
|
||||||
|
const input = event.target;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const examId = input.dataset.examId;
|
||||||
|
input.value = '';
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
toast('正在导入报到状态', `${file.name} · 导入结果只会暂存`);
|
||||||
|
const result = await api(`/api/admission/reporting/import?examId=${encodeURIComponent(examId)}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() });
|
||||||
|
state.reportingImportSummaries[examId] = result;
|
||||||
|
toast(result.changedCount ? 'Excel 已导入并暂存' : 'Excel 已读取,状态没有变化', result.changedCount ? `读取 ${result.count} 行,实际更新 ${result.changedCount} 人,${result.unchangedCount} 人未变化` : `读取 ${result.count} 行,内容与当前暂存状态一致`);
|
||||||
|
await renderRoute();
|
||||||
|
} catch (error) { toast('Excel 导入失败', error.message); }
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-reporting-qr-file]')) {
|
||||||
|
const input = event.target;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
input.value = '';
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!('BarcodeDetector' in window)) throw new Error('当前浏览器不支持图片二维码识别,请粘贴二维码中的核验链接');
|
||||||
|
const detector = new BarcodeDetector({ formats: ['qr_code'] });
|
||||||
|
const codes = await detector.detect(file);
|
||||||
|
const code = codes[0]?.rawValue || '';
|
||||||
|
if (!code) throw new Error('图片中没有识别到二维码');
|
||||||
|
await previewReportingScan(code, input.dataset.examId || '');
|
||||||
|
} catch (error) { toast('二维码识别失败', error.message); }
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([refreshPublic(), refreshSession()]);
|
await Promise.all([refreshPublic(), refreshSession()]);
|
||||||
|
|||||||
+10
-1
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
|
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
|
||||||
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
|
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
|
||||||
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
|
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
|
||||||
|
import { CURRENT_SCHEMA_VERSION } from './src/database/version.mjs';
|
||||||
|
|
||||||
export const relationalTables = [
|
export const relationalTables = [
|
||||||
'schema_metadata',
|
'schema_metadata',
|
||||||
@@ -60,7 +61,7 @@ export function buildSeedOperations(state) {
|
|||||||
const nullable = value => value == null || value === '' ? null : value;
|
const nullable = value => value == null || value === '' ? null : value;
|
||||||
|
|
||||||
add(
|
add(
|
||||||
'UPDATE schema_metadata SET schema_version = 20, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
`UPDATE schema_metadata SET schema_version = ${CURRENT_SCHEMA_VERSION}, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1`,
|
||||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||||
state.meta?.createdAt || new Date().toISOString()
|
state.meta?.createdAt || new Date().toISOString()
|
||||||
);
|
);
|
||||||
@@ -1246,6 +1247,14 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
auditOperation(log)
|
auditOperation(log)
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
async updateFeatureScores(entries) {
|
||||||
|
const operations = [];
|
||||||
|
for (const { registration, log } of entries) {
|
||||||
|
operations.push(operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id));
|
||||||
|
operations.push(auditOperation(log));
|
||||||
|
}
|
||||||
|
await transaction(operations);
|
||||||
|
},
|
||||||
async saveAdmissionRecord(record, log = null) {
|
async saveAdmissionRecord(record, log = null) {
|
||||||
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
||||||
`INSERT INTO admission_records (
|
`INSERT INTO admission_records (
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ const resourceSpecs = {
|
|||||||
results: {
|
results: {
|
||||||
title: '考试成绩台账', sheet: '成绩',
|
title: '考试成绩台账', sheet: '成绩',
|
||||||
columns: [
|
columns: [
|
||||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['candidateName', '姓名(只读参考)', 16, ''],
|
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['cardNumber', '准考证号(只读参考)', 24, ''],
|
||||||
|
['candidateName', '姓名(只读参考)', 16, ''], ['schoolName', '学校(只读参考)', 24, ''], ['className', '班级(只读参考)', 20, ''],
|
||||||
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
|
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
|
||||||
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
|
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
|
||||||
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
|
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
|
||||||
@@ -123,6 +124,48 @@ const resourceSpecs = {
|
|||||||
],
|
],
|
||||||
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
||||||
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||||
|
},
|
||||||
|
admission_preferences: {
|
||||||
|
title: '考生志愿填报实时台账', sheet: '志愿填报情况',
|
||||||
|
columns: [
|
||||||
|
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'],
|
||||||
|
['round', '填报轮次', 12, 1], ['fillStatus', '填报状态', 16, '已填报'], ['lockStatus', '锁定状态', 16, '未锁定'],
|
||||||
|
['submissionCount', '已提交次数', 14, 1], ['maxSubmissions', '提交次数上限', 16, 3],
|
||||||
|
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||||
|
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||||
|
['specialty', '特长生资格', 22, '普通生'], ['indicatorStatus', '指标分配资格', 18, '有资格'],
|
||||||
|
['preferenceOrder', '志愿顺序', 14, 1], ['preferenceType', '志愿类型', 14, '普通志愿'],
|
||||||
|
['targetSchoolCode', '志愿学校代码', 18, 'AD01'], ['targetSchoolName', '志愿学校', 28, '海州市高级中学'],
|
||||||
|
['categoryName', '招生类别', 20, '普通生'], ['submittedAt', '最近提交时间', 24, '2026-07-22 10:18']
|
||||||
|
],
|
||||||
|
numberColumns: ['round', 'submissionCount', 'maxSubmissions', 'preferenceOrder'],
|
||||||
|
numberFormats: { round: '0', submissionCount: '0', maxSubmissions: '0', preferenceOrder: '0' }
|
||||||
|
},
|
||||||
|
admission_placements: {
|
||||||
|
title: '招生录取情况台账', sheet: '录取情况',
|
||||||
|
columns: [
|
||||||
|
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'], ['round', '录取轮次', 12, 1],
|
||||||
|
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||||
|
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||||
|
['specialty', '特长生资格', 22, '普通生'], ['culturalScore', '文化课总分', 14, 560], ['featureScore', '特征分', 12, 0], ['totalScore', '投档总分', 14, 560],
|
||||||
|
['preferenceOrder', '命中志愿序号', 16, 1], ['admissionSchoolCode', '招生学校代码', 18, 'AD01'], ['admissionSchoolName', '招生学校', 28, '海州市高级中学'],
|
||||||
|
['categoryName', '招生类别', 20, '普通生'], ['quotaBucket', '计划类型', 18, '普通计划'], ['admissionStatus', '录取状态', 18, '正式录取'],
|
||||||
|
['reportingStatus', '报到状态', 16, '已报到'], ['noticeNumber', '录取通知书编号', 34, 'AD01-EX-2026-ZK-000001'],
|
||||||
|
['withdrawalReason', '退档或放弃原因', 34, ''], ['updatedAt', '状态更新时间', 24, '2026-07-22 10:18']
|
||||||
|
],
|
||||||
|
numberColumns: ['round', 'culturalScore', 'featureScore', 'totalScore', 'preferenceOrder'],
|
||||||
|
numberFormats: { round: '0', culturalScore: '0.00', featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||||
|
},
|
||||||
|
admission_reporting: {
|
||||||
|
title: '录取考生报到状态维护表', sheet: '考生报到',
|
||||||
|
columns: [
|
||||||
|
['noticeNumber', '录取通知书编号*', 34, 'AD01-EX-2026-ZK-000001'],
|
||||||
|
['candidateNumber', '报名号*', 26, '2026-HZ01-F-0001'], ['name', '姓名(只读)', 14, '张同学'],
|
||||||
|
['examCode', '考试代码(只读)', 20, 'EX-2026-ZK'], ['schoolCode', '招生学校代码(只读)', 18, 'AD01'],
|
||||||
|
['categoryName', '录取类别(只读)', 20, '普通生'],
|
||||||
|
['reportingStatusCode', '报到状态码*(Y/N/P)', 22, 'P'], ['reportingNote', '报到备注', 36, '']
|
||||||
|
],
|
||||||
|
validations: { reportingStatusCode: ['Y', 'N', 'P'] }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,7 +242,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
|||||||
header.alignment = { vertical: 'middle', horizontal: 'center' };
|
header.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
||||||
for (const item of outputRows) {
|
for (const item of outputRows) {
|
||||||
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? ''])));
|
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, resource === 'admission_reporting' && key === 'reportingNote' && !item[key] ? null : item[key] ?? ''])));
|
||||||
row.height = 23;
|
row.height = 23;
|
||||||
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
||||||
row.alignment = { vertical: 'middle' };
|
row.alignment = { vertical: 'middle' };
|
||||||
@@ -207,6 +250,14 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
|||||||
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (resource === 'admission_reporting') {
|
||||||
|
const statusColumn = spec.columns.findIndex(([key]) => key === 'reportingStatusCode') + 1;
|
||||||
|
const noteColumn = spec.columns.findIndex(([key]) => key === 'reportingNote') + 1;
|
||||||
|
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
|
||||||
|
for (const column of [statusColumn, noteColumn]) sheet.getCell(row, column).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF3CD' } };
|
||||||
|
}
|
||||||
|
sheet.getCell('A1').value = `${spec.title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认`;
|
||||||
|
}
|
||||||
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
||||||
for (const [key, values] of Object.entries(spec.validations || {})) {
|
for (const [key, values] of Object.entries(spec.validations || {})) {
|
||||||
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
<meta name="theme-color" content="#132451" />
|
<meta name="theme-color" content="#132451" />
|
||||||
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
|
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
|
||||||
<title>衡准 · 考试信息管理系统</title>
|
<title>衡准 · 考试信息管理系统</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css?v=20260722" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" aria-live="polite">
|
<div id="app" aria-live="polite">
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
<span class="toast-icon">✓</span>
|
<span class="toast-icon">✓</span>
|
||||||
<div><strong>操作成功</strong><small>更改已保存</small></div>
|
<div><strong>操作成功</strong><small>更改已保存</small></div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js?v=20260722"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs",
|
"test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
|
||||||
"test:cache": "node tests/cache.test.mjs",
|
"test:cache": "node tests/cache.test.mjs",
|
||||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { loadEnvFile } from 'node:process';
|
|||||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
|
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
const envPath = join(root, '.env');
|
const envPath = join(root, '.env');
|
||||||
@@ -101,8 +102,8 @@ async function prepareMysql() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
|
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
|
||||||
if (Number(metadataRows[0]?.schema_version) !== 16) {
|
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
|
||||||
throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [examPartitionRows] = await connection.query(
|
const [examPartitionRows] = await connection.query(
|
||||||
@@ -127,8 +128,8 @@ async function prepareMysql() {
|
|||||||
let recognizedTestData = false;
|
let recognizedTestData = false;
|
||||||
if (initializeEmpty && nonEmpty.length) {
|
if (initializeEmpty && nonEmpty.length) {
|
||||||
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
||||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7')");
|
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
|
||||||
recognizedTestData = Number(bulkUsers.count) >= 300 && Number(testSchools.count) === 4;
|
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
|
||||||
}
|
}
|
||||||
if (nonEmpty.length && !force && !recognizedTestData) {
|
if (nonEmpty.length && !force && !recognizedTestData) {
|
||||||
const forceCommand = initializeEmpty
|
const forceCommand = initializeEmpty
|
||||||
@@ -193,8 +194,9 @@ if (initializeEmpty) {
|
|||||||
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||||
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||||
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||||
|
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
|
||||||
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||||
console.log('all predefined test account passwords: 12345678');
|
console.log('all predefined test account passwords: 12345678');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,4 @@
|
|||||||
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
// Keep the historical reset-db entry point, but run the same guarded initializer used
|
||||||
import { rm } from 'node:fs/promises';
|
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
|
||||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
|
||||||
import { createDatabase } from '../database.mjs';
|
await import('./import-test-data.mjs');
|
||||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
|
||||||
const databasePath = join(root, 'data', 'exam.sqlite');
|
|
||||||
const relativePath = relative(root, databasePath);
|
|
||||||
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝重建工作区以外的数据库');
|
|
||||||
|
|
||||||
for (const path of [databasePath, `${databasePath}-shm`, `${databasePath}-wal`]) await rm(path, { force: true });
|
|
||||||
|
|
||||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
|
||||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
|
||||||
return `${salt}:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env.DATABASE_CLIENT = 'sqlite';
|
|
||||||
process.env.SQLITE_PATH = databasePath;
|
|
||||||
const database = await createDatabase({
|
|
||||||
root,
|
|
||||||
seed: () => createBaseDatabase({
|
|
||||||
nowIso: () => new Date().toISOString(),
|
|
||||||
hashPassword,
|
|
||||||
initialAdmin: {
|
|
||||||
username: process.env.INITIAL_ADMIN_USERNAME,
|
|
||||||
password: process.env.INITIAL_ADMIN_PASSWORD,
|
|
||||||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const state = await database.read();
|
|
||||||
await database.close();
|
|
||||||
console.log(`Rebuilt empty ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates)`);
|
|
||||||
|
|||||||
+98
-29
@@ -17,10 +17,13 @@ import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './s
|
|||||||
import { createBaseDatabase } from './src/data/base.mjs';
|
import { createBaseDatabase } from './src/data/base.mjs';
|
||||||
import { resolveRegion } from './src/data/region-service.mjs';
|
import { resolveRegion } from './src/data/region-service.mjs';
|
||||||
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
|
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
|
||||||
|
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from './src/security/document-verification.mjs';
|
||||||
|
import { admissionRecords, assignAdmissionNoticeNumbers } from './src/services/volunteer-admission.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
const envPath = join(root, '.env');
|
const envPath = join(root, '.env');
|
||||||
if (existsSync(envPath)) loadEnvFile(envPath);
|
if (existsSync(envPath)) loadEnvFile(envPath);
|
||||||
|
const documentVerificationSecret = resolveDocumentVerificationSecret();
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 4173);
|
const port = Number(process.env.PORT || 4173);
|
||||||
const host = process.env.HOST || '127.0.0.1';
|
const host = process.env.HOST || '127.0.0.1';
|
||||||
@@ -51,6 +54,8 @@ const staticFiles = new Set([
|
|||||||
'/src/client/public-views.mjs',
|
'/src/client/public-views.mjs',
|
||||||
'/src/client/state.mjs',
|
'/src/client/state.mjs',
|
||||||
'/src/client/ui.mjs',
|
'/src/client/ui.mjs',
|
||||||
|
'/src/client/table-state.mjs',
|
||||||
|
'/src/client/pdf-export.mjs',
|
||||||
'/src/client/region-select.mjs',
|
'/src/client/region-select.mjs',
|
||||||
'/src/data/china-regions.mjs',
|
'/src/data/china-regions.mjs',
|
||||||
'/src/data/specialty-types.mjs'
|
'/src/data/specialty-types.mjs'
|
||||||
@@ -100,7 +105,7 @@ const initializeDatabase = () => createBaseDatabase({
|
|||||||
});
|
});
|
||||||
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
||||||
const cache = await createRedisCache();
|
const cache = await createRedisCache();
|
||||||
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateExam', 'archiveExam']);
|
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']);
|
||||||
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
||||||
const namespaces = ['public'];
|
const namespaces = ['public'];
|
||||||
const instance = args[0];
|
const instance = args[0];
|
||||||
@@ -112,6 +117,9 @@ const database = withCacheInvalidation(persistentDatabase, cache, (method, args)
|
|||||||
return namespaces;
|
return namespaces;
|
||||||
});
|
});
|
||||||
const readDb = () => database.read();
|
const readDb = () => database.read();
|
||||||
|
const documentNumberDb = await readDb();
|
||||||
|
const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber);
|
||||||
|
if (missingNoticeNumbers.length) await database.saveAdmissionRecords(assignAdmissionNoticeNumbers(documentNumberDb, missingNoticeNumbers));
|
||||||
|
|
||||||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
||||||
const requirePermission = createPermissionGuard(sendError);
|
const requirePermission = createPermissionGuard(sendError);
|
||||||
@@ -357,12 +365,46 @@ function gradeForRank(rank, cohortSize) {
|
|||||||
return 'D';
|
return 'D';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resultIndexCache = new WeakMap();
|
||||||
|
const examSummaryCache = new WeakMap();
|
||||||
|
|
||||||
|
function resultIndexes(db) {
|
||||||
|
let indexes = resultIndexCache.get(db);
|
||||||
|
if (indexes) return indexes;
|
||||||
|
const publishedScoresBySubject = new Map();
|
||||||
|
const publishedByRegistration = new Map();
|
||||||
|
for (const result of db.results) {
|
||||||
|
if (!result.published) continue;
|
||||||
|
if (!publishedScoresBySubject.has(result.subjectId)) publishedScoresBySubject.set(result.subjectId, []);
|
||||||
|
publishedScoresBySubject.get(result.subjectId).push(Number(result.score));
|
||||||
|
if (!publishedByRegistration.has(result.registrationId)) publishedByRegistration.set(result.registrationId, []);
|
||||||
|
publishedByRegistration.get(result.registrationId).push(result);
|
||||||
|
}
|
||||||
|
for (const scores of publishedScoresBySubject.values()) scores.sort((left, right) => left - right);
|
||||||
|
indexes = { publishedScoresBySubject, publishedByRegistration };
|
||||||
|
resultIndexCache.set(db, indexes);
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countScoresGreaterThan(sortedScores, score) {
|
||||||
|
let low = 0;
|
||||||
|
let high = sortedScores.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.floor((low + high) / 2);
|
||||||
|
if (sortedScores[middle] <= score) low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
return sortedScores.length - low;
|
||||||
|
}
|
||||||
|
|
||||||
function resultRankInfo(db, result, scoreOverride = result?.score) {
|
function resultRankInfo(db, result, scoreOverride = result?.score) {
|
||||||
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
|
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
|
||||||
const score = Number(scoreOverride);
|
const score = Number(scoreOverride);
|
||||||
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
|
const scores = resultIndexes(db).publishedScoresBySubject.get(result.subjectId) || [];
|
||||||
const cohortSize = peers.length + 1;
|
const cohortSize = scores.length + (result.published ? 0 : 1);
|
||||||
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
|
let greater = countScoresGreaterThan(scores, score);
|
||||||
|
if (result.published && Number(result.score) > score) greater -= 1;
|
||||||
|
const rank = 1 + Math.max(0, greater);
|
||||||
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
|
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
|
||||||
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
|
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
|
||||||
}
|
}
|
||||||
@@ -379,15 +421,43 @@ function subjectPassEvaluation(db, result, subject, scoreOverride = result?.scor
|
|||||||
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
|
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function examResultSummary(db, registration) {
|
function examSummaryIndexes(db) {
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
let indexes = examSummaryCache.get(db);
|
||||||
if (!exam) return null;
|
if (indexes) return indexes;
|
||||||
|
const examById = new Map(db.exams.map(exam => [exam.id, exam]));
|
||||||
|
const publishedByRegistration = resultIndexes(db).publishedByRegistration;
|
||||||
|
const baseByRegistration = new Map();
|
||||||
|
const totalsByCohort = new Map();
|
||||||
|
for (const registration of db.registrations) {
|
||||||
|
if (registration.status !== 'approved') continue;
|
||||||
|
const exam = examById.get(registration.examId);
|
||||||
|
if (!exam) continue;
|
||||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||||
const published = db.results.filter(result => result.registrationId === registration.id && result.published);
|
const published = publishedByRegistration.get(registration.id) || [];
|
||||||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||||||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||||||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||||||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||||
|
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||||
|
const cohortKey = `${exam.id}\u0000${subjectKey}`;
|
||||||
|
const base = { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey };
|
||||||
|
baseByRegistration.set(registration.id, base);
|
||||||
|
if (complete) {
|
||||||
|
if (!totalsByCohort.has(cohortKey)) totalsByCohort.set(cohortKey, []);
|
||||||
|
totalsByCohort.get(cohortKey).push(total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const totals of totalsByCohort.values()) totals.sort((left, right) => left - right);
|
||||||
|
indexes = { baseByRegistration, totalsByCohort };
|
||||||
|
examSummaryCache.set(db, indexes);
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function examResultSummary(db, registration) {
|
||||||
|
const summaryIndexes = examSummaryIndexes(db);
|
||||||
|
const base = summaryIndexes.baseByRegistration.get(registration.id);
|
||||||
|
if (!base) return null;
|
||||||
|
const { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey } = base;
|
||||||
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
|
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
|
||||||
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
|
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
|
||||||
const value = Number(exam.passValue ?? 60);
|
const value = Number(exam.passValue ?? 60);
|
||||||
@@ -402,17 +472,9 @@ function examResultSummary(db, registration) {
|
|||||||
});
|
});
|
||||||
if (complete && policy === 'none') qualified = null;
|
if (complete && policy === 'none') qualified = null;
|
||||||
if (complete && policy === 'rank_percent') {
|
if (complete && policy === 'rank_percent') {
|
||||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
const totals = summaryIndexes.totalsByCohort.get(cohortKey) || [];
|
||||||
const totals = db.registrations
|
|
||||||
.filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
|
|
||||||
.map(item => {
|
|
||||||
const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
|
|
||||||
if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
|
|
||||||
return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
|
|
||||||
})
|
|
||||||
.filter(item => item != null);
|
|
||||||
cohortSize = totals.length;
|
cohortSize = totals.length;
|
||||||
rank = 1 + totals.filter(item => item > total).length;
|
rank = 1 + countScoresGreaterThan(totals, total);
|
||||||
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
|
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,24 +620,27 @@ function excelRowsForResource(db, user, resource, searchParams) {
|
|||||||
});
|
});
|
||||||
if (resource === 'results') {
|
if (resource === 'results') {
|
||||||
const examId = cleanText(searchParams.get('examId'), 64);
|
const examId = cleanText(searchParams.get('examId'), 64);
|
||||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item) && (!examId || item.examId === examId));
|
||||||
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
|
return scopedRegistrations.flatMap(registration => {
|
||||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
|
||||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
|
||||||
const account = db.users.find(item => item.id === registration?.userId);
|
const account = db.users.find(item => item.id === registration?.userId);
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||||
const evaluation = subjectPassEvaluation(db, result, subject);
|
const schoolClass = db.classes.find(item => item.id === profile?.classId);
|
||||||
const rank = resultRankInfo(db, result);
|
return (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)).map(subject => {
|
||||||
|
const result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
|
||||||
|
const evaluation = result ? subjectPassEvaluation(db, result, subject) : null;
|
||||||
|
const rank = result ? resultRankInfo(db, result) : null;
|
||||||
return {
|
return {
|
||||||
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
|
candidateNumber: account?.candidateNumber || registration.registrationNumber || '', cardNumber: registration.admitCard?.number || '',
|
||||||
|
candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
|
||||||
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
|
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
|
||||||
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score,
|
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation?.passScore ?? '', score: result?.score ?? '',
|
||||||
rank: rank.rank, rankPercent: rank.rankPercent,
|
rank: rank?.rank ?? '', rankPercent: rank?.rankPercent ?? '',
|
||||||
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
|
qualified: !result || evaluation?.qualified == null ? '' : evaluation.qualified ? '达线' : '未达线',
|
||||||
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
|
grade: result ? (result.published ? rank?.grade || '' : '待发布') : '', published: result?.published ? '发布' : '不发布', updatedAt: result?.updatedAt || result?.publishedAt || ''
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (resource === 'admit_cards') {
|
if (resource === 'admit_cards') {
|
||||||
const examId = cleanText(searchParams.get('examId'), 64);
|
const examId = cleanText(searchParams.get('examId'), 64);
|
||||||
@@ -845,6 +910,10 @@ const routeContext = {
|
|||||||
database,
|
database,
|
||||||
cache,
|
cache,
|
||||||
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
|
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
|
||||||
|
documentVerificationSecret,
|
||||||
|
scoreReportCode,
|
||||||
|
admissionNoticeCode,
|
||||||
|
safeCodeEqual,
|
||||||
readDb,
|
readDb,
|
||||||
publicSiteConfig,
|
publicSiteConfig,
|
||||||
sendJson,
|
sendJson,
|
||||||
|
|||||||
+195
-59
File diff suppressed because one or more lines are too long
@@ -1,37 +1,87 @@
|
|||||||
export function createAdmissionViews(context) {
|
export function createAdmissionViews(context) {
|
||||||
const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context;
|
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
|
||||||
const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
|
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
|
||||||
|
|
||||||
function shell(page, content, title, description) {
|
function shell(page, content, title, description) {
|
||||||
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>${nav.map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).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>`;
|
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) {
|
async function renderAdmission(page) {
|
||||||
if (state.user?.role !== 'admission_school') return navigate('login');
|
if (state.user?.role !== 'admission_school') return requireLogin();
|
||||||
if (!nav.some(item => item[0] === page)) page = 'dashboard';
|
if (!nav.some(item => item[0] === page)) page = 'dashboard';
|
||||||
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
|
const meta = { dashboard:['招生工作台','查看本校计划完成率、报到进度与待办事项。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
|
||||||
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
|
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
|
||||||
try {
|
try {
|
||||||
const endpoint = page === 'dashboard' ? 'context' : page;
|
const endpoint = page === 'dashboard' ? 'context' : page;
|
||||||
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
|
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
|
||||||
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : placements(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]);
|
app.innerHTML = shell(page, content, ...meta[page]);
|
||||||
} catch (error) { renderError(error); }
|
} catch (error) { renderError(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashboard(data) {
|
function dashboard(data) {
|
||||||
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></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></section></div>`;
|
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) {
|
function plans(data) {
|
||||||
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="table-scroll"><table><thead><tr><th>考试</th><th>类别计划</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${data.plans.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>${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="5" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div></section>`;
|
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) {
|
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 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>` : '';
|
||||||
return `${exportBar}<section class="panel data-panel"><div class="panel-title"><div><h2>本校投档名单</h2><p>显示投档所需的考生信息与当次成绩,不包含其余志愿。</p></div><span>${data.placements.length} 人</span></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>审核</th></tr></thead><tbody>${data.placements.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</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="退档须填写特殊理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div></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 };
|
return { renderAdmission };
|
||||||
}
|
}
|
||||||
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
|
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
|
||||||
import { specialtyLabel } from '../data/specialty-types.mjs';
|
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
import { filterTableItems } from './table-state.mjs';
|
||||||
|
|||||||
+5
-1
@@ -8,6 +8,10 @@ export async function api(path, options = {}) {
|
|||||||
});
|
});
|
||||||
const type = response.headers.get('content-type') || '';
|
const type = response.headers.get('content-type') || '';
|
||||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||||
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试');
|
if (!response.ok) {
|
||||||
|
const error = new Error(data?.message || '操作未完成,请稍后重试');
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createCandidateViews(context) {
|
|||||||
icons,
|
icons,
|
||||||
api,
|
api,
|
||||||
renderError,
|
renderError,
|
||||||
|
requireLogin,
|
||||||
emptyState,
|
emptyState,
|
||||||
brand
|
brand
|
||||||
} = context;
|
} = context;
|
||||||
@@ -30,14 +31,19 @@ export function createCandidateViews(context) {
|
|||||||
const security = ['security', '账户安全', 'user'];
|
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 === '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];
|
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], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], 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) {
|
function portalShell(role, page, content, title, description) {
|
||||||
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||||
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
||||||
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>${nav.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('')}</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>`;
|
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) {
|
function portalHeadingAction(role, page) {
|
||||||
@@ -73,7 +79,7 @@ export function createCandidateViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderCandidate(page) {
|
async function renderCandidate(page) {
|
||||||
if (state.user?.role !== 'candidate') return navigate('login');
|
if (state.user?.role !== 'candidate') return requireLogin();
|
||||||
app.classList.remove('admin-readable');
|
app.classList.remove('admin-readable');
|
||||||
if (state.user.mustChangePassword) {
|
if (state.user.mustChangePassword) {
|
||||||
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
||||||
@@ -104,7 +110,7 @@ export function createCandidateViews(context) {
|
|||||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||||
try {
|
try {
|
||||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
|
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
|
||||||
const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
|
const data = page === 'notices' ? await api('/api/candidate/notices') : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
|
||||||
state.pageData = data;
|
state.pageData = data;
|
||||||
if (data.profile) state.profile = data.profile;
|
if (data.profile) state.profile = data.profile;
|
||||||
const content = {
|
const content = {
|
||||||
@@ -187,20 +193,24 @@ export function createCandidateViews(context) {
|
|||||||
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
|
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>`;
|
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('');
|
}).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><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
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;
|
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>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateAdmissions(data) {
|
function candidateAdmissions(data) {
|
||||||
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', supplementary: '补录填报中', completed: '录取结束' };
|
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' };
|
||||||
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
|
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
|
||||||
return `${data.notifications?.length ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
|
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 choices = item.preference?.payload?.choices || [];
|
||||||
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked;
|
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false;
|
||||||
const placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '';
|
const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校';
|
||||||
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
||||||
const progressIndex = item.status === 'supplementary' ? 1 : Math.max(0, progressSteps.indexOf(item.status));
|
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 indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
|
||||||
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
|
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
|
||||||
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
|
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
|
||||||
@@ -212,10 +222,10 @@ export function createCandidateViews(context) {
|
|||||||
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>`;
|
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 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); return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).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 qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
|
||||||
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
|
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><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></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.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
|
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>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const A4 = { width: 2480, height: 3508 };
|
||||||
|
|
||||||
|
function roundRect(ctx, x, y, width, height, radius = 18) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x, y, width, height, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitText(ctx, text, maxWidth, initialSize, weight = 400) {
|
||||||
|
let size = initialSize;
|
||||||
|
do {
|
||||||
|
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||||
|
if (ctx.measureText(String(text)).width <= maxWidth) return size;
|
||||||
|
size -= 2;
|
||||||
|
} while (size > 24);
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawText(ctx, text, x, y, { size = 36, weight = 400, color = '#17213f', align = 'left', maxWidth } = {}) {
|
||||||
|
if (maxWidth) size = fitText(ctx, text, maxWidth, size, weight);
|
||||||
|
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = align;
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
ctx.fillText(String(text ?? ''), x, y, maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpegPdf(dataUrl, width, height) {
|
||||||
|
const binary = atob(dataUrl.split(',')[1]);
|
||||||
|
const image = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const chunks = [];
|
||||||
|
const offsets = [0];
|
||||||
|
let length = 0;
|
||||||
|
const add = value => { const bytes = typeof value === 'string' ? encoder.encode(value) : value; chunks.push(bytes); length += bytes.length; };
|
||||||
|
add('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
|
||||||
|
const object = (id, body) => { offsets[id] = length; add(`${id} 0 obj\n${body}\nendobj\n`); };
|
||||||
|
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
|
||||||
|
object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
|
||||||
|
object(3, '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
|
||||||
|
offsets[4] = length;
|
||||||
|
add(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`);
|
||||||
|
add(image); add('\nendstream\nendobj\n');
|
||||||
|
const stream = 'q\n595.28 0 0 841.89 0 0 cm\n/Im0 Do\nQ';
|
||||||
|
object(5, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
||||||
|
const xref = length;
|
||||||
|
add(`xref\n0 6\n0000000000 65535 f \n`);
|
||||||
|
for (let id = 1; id <= 5; id += 1) add(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
|
||||||
|
add(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
|
||||||
|
const output = new Uint8Array(length);
|
||||||
|
let cursor = 0;
|
||||||
|
for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.length; }
|
||||||
|
return new Blob([output], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCanvasPdf(canvas, filename) {
|
||||||
|
const blob = jpegPdf(canvas.toDataURL('image/jpeg', .94), canvas.width, canvas.height);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(link.href), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('防伪二维码加载失败'));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawQrCode(ctx, dataUrl, x, y, size) {
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const image = await loadImage(dataUrl);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(x - 10, y - 10, size + 20, size + 20);
|
||||||
|
ctx.drawImage(image, x, y, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl, verificationQr }) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
Object.assign(canvas, A4);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#f5f8fb'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#14234b'; ctx.fillRect(0, 0, canvas.width, 270);
|
||||||
|
ctx.fillStyle = '#c94b45'; ctx.fillRect(170, 234, 250, 12);
|
||||||
|
drawText(ctx, organization?.name || '考试服务平台', 170, 112, { size: 42, weight: 700, color: '#ffffff' });
|
||||||
|
drawText(ctx, '考 生 成 绩 单', 170, 205, { size: 76, weight: 700, color: '#ffffff' });
|
||||||
|
drawText(ctx, exam.code, 2300, 115, { size: 32, weight: 600, color: '#9eadce', align: 'right' });
|
||||||
|
drawText(ctx, exam.name, 2300, 192, { size: 38, weight: 500, color: '#ffffff', align: 'right', maxWidth: 1120 });
|
||||||
|
|
||||||
|
const box = (x, y, w, h, fill = '#ffffff') => { roundRect(ctx, x, y, w, h, 22); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#dce4ec'; ctx.lineWidth = 2; ctx.stroke(); };
|
||||||
|
box(170, 330, 2140, 300);
|
||||||
|
const meta = [['姓名', candidate.name], ['报名号', candidate.candidateNumber], ['考试', exam.name], ['发布时间', summary?.publishedAt ? new Date(summary.publishedAt).toLocaleString('zh-CN') : '以系统记录为准']];
|
||||||
|
meta.forEach(([label, value], index) => {
|
||||||
|
const x = 225 + (index % 2) * 1050, y = 420 + Math.floor(index / 2) * 115;
|
||||||
|
drawText(ctx, label, x, y, { size: 28, color: '#77839a' });
|
||||||
|
drawText(ctx, value, x + 155, y, { size: 34, weight: 600, maxWidth: 800 });
|
||||||
|
});
|
||||||
|
box(170, 690, 2140, 320, '#eaf4f1');
|
||||||
|
const totals = [['总分', `${summary?.total ?? '—'} / ${summary?.fullScore ?? '—'}`], ['特征分', summary?.featureScore ?? 0], ['合格结论', summary?.qualified == null ? '不判定' : summary.qualified ? '合格' : '未合格'], ['发布进度', `${summary?.publishedSubjects ?? results.length} / ${summary?.subjectCount ?? results.length} 科`]];
|
||||||
|
totals.forEach(([label, value], index) => {
|
||||||
|
const x = 235 + index * 520;
|
||||||
|
drawText(ctx, label, x, 790, { size: 28, color: '#5d766f' });
|
||||||
|
drawText(ctx, value, x, 900, { size: 48, weight: 700, color: '#173b35', maxWidth: 440 });
|
||||||
|
});
|
||||||
|
|
||||||
|
drawText(ctx, '科目成绩与等级排名', 170, 1115, { size: 42, weight: 700 });
|
||||||
|
drawText(ctx, '等级与排名均以系统正式发布数据为准', 2310, 1115, { size: 25, color: '#7b8598', align: 'right' });
|
||||||
|
const cols = 2, gap = 34, cardW = (2140 - gap) / cols, cardH = Math.min(300, Math.max(220, (1760 - Math.ceil(results.length / cols) * 20) / Math.ceil(results.length / cols)));
|
||||||
|
results.forEach((item, index) => {
|
||||||
|
const col = index % cols, row = Math.floor(index / cols), x = 170 + col * (cardW + gap), y = 1180 + row * (cardH + 20);
|
||||||
|
box(x, y, cardW, cardH);
|
||||||
|
drawText(ctx, item.subjectName, x + 42, y + 72, { size: 38, weight: 700, maxWidth: cardW - 450 });
|
||||||
|
drawText(ctx, item.qualified == null ? '不判定' : item.qualified ? '达线' : '未达线', x + cardW - 42, y + 70, { size: 27, weight: 600, color: item.qualified === false ? '#b43d38' : '#2d7462', align: 'right' });
|
||||||
|
drawText(ctx, item.score, x + 42, y + 158, { size: 62, weight: 700 });
|
||||||
|
drawText(ctx, `/ ${item.fullScore}`, x + 190, y + 156, { size: 28, color: '#8993a6' });
|
||||||
|
drawText(ctx, `${item.grade} · 第 ${item.rank} / ${item.cohortSize} 名 · 前 ${item.rankPercent}%`, x + 42, y + 220, { size: 28, color: '#455068', maxWidth: cardW - 84 });
|
||||||
|
drawText(ctx, item.passText || '不设单科线', x + 42, y + cardH - 30, { size: 24, color: '#7c8798', maxWidth: cardW - 84 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const footerY = 3100;
|
||||||
|
box(170, footerY, 2140, 235, '#f0f3f7');
|
||||||
|
drawText(ctx, '防伪查询码', 225, footerY + 70, { size: 28, color: '#6f7a8e' });
|
||||||
|
drawText(ctx, verificationCode, 225, footerY + 135, { size: 38, weight: 700, color: '#17213f' });
|
||||||
|
drawText(ctx, '登录考试服务平台,在“文书防伪查询”中输入本码核验。', 225, footerY + 188, { size: 24, color: '#667085' });
|
||||||
|
drawText(ctx, verificationUrl, 2035, footerY + 135, { size: 21, color: '#53627b', align: 'right', maxWidth: 820 });
|
||||||
|
await drawQrCode(ctx, verificationQr, 2075, footerY + 26, 180);
|
||||||
|
drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
|
||||||
|
downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl, verificationQr, noticeNumber }) {
|
||||||
|
const canvas = document.createElement('canvas'); Object.assign(canvas, A4);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const primary = template.primaryColor || '#8d2028', accent = template.accentColor || '#c9a45b';
|
||||||
|
ctx.fillStyle = '#fffdf8'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.strokeStyle = primary; ctx.lineWidth = 10; ctx.strokeRect(90, 90, 2300, 3328);
|
||||||
|
ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.strokeRect(120, 120, 2240, 3268);
|
||||||
|
drawText(ctx, template.eyebrow || 'ADMISSION NOTICE', 1240, 350, { size: 30, weight: 600, color: accent, align: 'center' });
|
||||||
|
drawText(ctx, template.title || '录 取 通 知 书', 1240, 560, { size: 96, weight: 700, color: primary, align: 'center', maxWidth: 1950 });
|
||||||
|
drawText(ctx, school.name, 1240, 700, { size: 42, weight: 600, align: 'center', maxWidth: 1900 });
|
||||||
|
drawText(ctx, `通知书编号:${noticeNumber || placement.payload.noticeNumber || '—'}`, 2180, 835, { size: 27, weight: 600, color: '#655d53', align: 'right', maxWidth: 1250 });
|
||||||
|
drawText(ctx, `${candidate.name} 同学:`, 300, 1040, { size: 48, weight: 700 });
|
||||||
|
const body = (template.body || '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。')
|
||||||
|
.replaceAll('{{考生姓名}}', candidate.name).replaceAll('{{考试名称}}', exam.name).replaceAll('{{录取学校}}', school.name).replaceAll('{{录取类别}}', placement.payload.categoryName || '招生类别');
|
||||||
|
const lines = [];
|
||||||
|
for (const paragraph of body.split(/\n+/)) {
|
||||||
|
let line = '';
|
||||||
|
for (const char of paragraph) {
|
||||||
|
ctx.font = '400 42px "Microsoft YaHei", sans-serif';
|
||||||
|
if (ctx.measureText(line + char).width > 1840) { lines.push(line); line = char; } else line += char;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line); lines.push('');
|
||||||
|
}
|
||||||
|
lines.slice(0, 13).forEach((line, index) => drawText(ctx, line, 320, 1210 + index * 82, { size: 42, color: '#332f2c' }));
|
||||||
|
drawText(ctx, template.footer || '请妥善保管本通知书,报到时出示。', 300, 2550, { size: 32, color: '#6c6257', maxWidth: 1700 });
|
||||||
|
drawText(ctx, school.name, 2080, 2750, { size: 38, weight: 700, color: primary, align: 'right' });
|
||||||
|
drawText(ctx, new Date().toLocaleDateString('zh-CN'), 2080, 2820, { size: 30, color: '#5f5951', align: 'right' });
|
||||||
|
roundRect(ctx, 240, 3040, 2000, 210, 20); ctx.fillStyle = '#f4efe5'; ctx.fill();
|
||||||
|
drawText(ctx, '防伪查询码', 300, 3115, { size: 26, color: '#756b5e' });
|
||||||
|
drawText(ctx, verificationCode, 300, 3185, { size: 35, weight: 700 });
|
||||||
|
drawText(ctx, verificationUrl, 1940, 3185, { size: 20, color: '#71695f', align: 'right', maxWidth: 820 });
|
||||||
|
await drawQrCode(ctx, verificationQr, 1995, 3055, 175);
|
||||||
|
drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
|
||||||
|
downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
|
||||||
|
}
|
||||||
+61
-10
@@ -1,3 +1,5 @@
|
|||||||
|
import { filterTableItems } from './table-state.mjs';
|
||||||
|
|
||||||
export function createPublicViews(context) {
|
export function createPublicViews(context) {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -20,7 +22,7 @@ export function createPublicViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function publicHeader() {
|
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="#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>`;
|
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() {
|
function renderHome() {
|
||||||
@@ -43,17 +45,56 @@ export function createPublicViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function noticeDocuments(data = state.publicAnnouncements) {
|
function noticeDocuments(data = state.publicAnnouncements) {
|
||||||
const ordinary = (state.publicData.notices || []).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
|
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 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: '录取名单', title: `${item.examName}最终录取名单`, 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 cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
|
||||||
return [...ordinary, ...qualifications, ...admissions, ...cutoffs].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
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) {
|
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 === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
|
||||||
if (document.documentType === 'qualification') return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${document.rows.map(row => `<tr><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('')}</tbody></table></div>`;
|
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 === 'admission') 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.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('')}</tbody></table></div>`;
|
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +109,14 @@ export function createPublicViews(context) {
|
|||||||
}
|
}
|
||||||
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
|
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
|
||||||
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
|
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
|
||||||
const filtered = category === '全部' ? documents : documents.filter(item => item.category === category);
|
const searched = filterTableItems(state, documents, 'publicNoticeDirectory');
|
||||||
|
const filtered = category === '全部' ? searched : searched.filter(item => item.category === category);
|
||||||
const pageSize = 8;
|
const pageSize = 8;
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
|
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
|
||||||
state.noticeCategory = category; state.noticePage = page;
|
state.noticeCategory = category; state.noticePage = page;
|
||||||
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize);
|
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="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>`;
|
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) {
|
function renderHeroTicket(exam) {
|
||||||
@@ -94,7 +136,16 @@ export function createPublicViews(context) {
|
|||||||
app.classList.remove('admin-readable');
|
app.classList.remove('admin-readable');
|
||||||
const login = kind === 'login';
|
const login = kind === 'login';
|
||||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||||
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>${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>`;
|
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() {
|
function loginForm() {
|
||||||
@@ -106,5 +157,5 @@ export function createPublicViews(context) {
|
|||||||
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 `<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 };
|
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ export const state = {
|
|||||||
user: null,
|
user: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||||
publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] },
|
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
|
||||||
noticeCategory: '全部',
|
noticeCategory: '全部',
|
||||||
noticePage: 1,
|
noticePage: 1,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
scopeLabel: '',
|
scopeLabel: '',
|
||||||
|
authNotice: '',
|
||||||
pageData: null,
|
pageData: null,
|
||||||
resultExamFilter: '',
|
resultExamFilter: '',
|
||||||
|
resultSubjectFilter: '',
|
||||||
|
resultExamCatalog: null,
|
||||||
resultImportPreview: null,
|
resultImportPreview: null,
|
||||||
|
reportingImportSummaries: {},
|
||||||
|
tablePages: {},
|
||||||
|
tableFilters: {},
|
||||||
loading: false
|
loading: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
export const statusLabels = {
|
export const statusLabels = {
|
||||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||||
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
||||||
supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', final: '正式录取', unread: '未读'
|
reporting: '考生报到中', supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', forfeited: '未报到失效', final: '正式录取', unread: '未读', submitted: '已提交', pending_approval: '待审批'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const icons = {
|
export const icons = {
|
||||||
|
|||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs';
|
||||||
|
|
||||||
const admissionNumberRules = (nowIso) => [
|
const admissionNumberRules = (nowIso) => [
|
||||||
{
|
{
|
||||||
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||||
@@ -63,7 +65,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
|||||||
const adminId = 'usr_admin';
|
const adminId = 'usr_admin';
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
return {
|
return {
|
||||||
meta: { version: 19, createdAt },
|
meta: { version: CURRENT_SCHEMA_VERSION, createdAt },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||||
schools: [], classes: [],
|
schools: [], classes: [],
|
||||||
|
|||||||
+156
-67
@@ -1,4 +1,4 @@
|
|||||||
export function createSeedDatabase({ nowIso, hashPassword }) {
|
export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) {
|
||||||
const adminId = 'usr_admin';
|
const adminId = 'usr_admin';
|
||||||
const schoolAdminId = 'usr_school_admin';
|
const schoolAdminId = 'usr_school_admin';
|
||||||
const schoolAdmin2Id = 'usr_school_admin_2';
|
const schoolAdmin2Id = 'usr_school_admin_2';
|
||||||
@@ -6,6 +6,39 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
const testPasswordHash = hashPassword('12345678');
|
const testPasswordHash = hashPassword('12345678');
|
||||||
|
const mainSubjectDefinitions = [
|
||||||
|
{ id: 'sub_chinese', name: '语文', date: '2026-06-20', start: '09:00', end: '11:00', fullScore: 120 },
|
||||||
|
{ id: 'sub_math', name: '数学', date: '2026-06-20', start: '14:30', end: '16:30', fullScore: 120 },
|
||||||
|
{ id: 'sub_english', name: '外语', date: '2026-06-21', start: '09:00', end: '11:00', fullScore: 120 },
|
||||||
|
{ id: 'sub_history', name: '历史', date: '2026-06-21', start: '14:30', end: '15:45', fullScore: 75 },
|
||||||
|
{ id: 'sub_politics', name: '政治', date: '2026-06-21', start: '16:10', end: '17:25', fullScore: 75 },
|
||||||
|
{ id: 'sub_physics', name: '物理', date: '2026-06-22', start: '09:00', end: '10:20', fullScore: 80 },
|
||||||
|
{ id: 'sub_chemistry', name: '化学', date: '2026-06-22', start: '10:45', end: '12:00', fullScore: 70 },
|
||||||
|
{ id: 'sub_experiment', name: '实验', date: '2026-06-22', start: '14:30', end: '15:00', fullScore: 20 },
|
||||||
|
{ id: 'sub_it', name: '信息技术', date: '2026-06-22', start: '15:30', end: '16:00', fullScore: 10 }
|
||||||
|
].map((subject, index) => ({
|
||||||
|
...subject, fee: 0, passRule: 'fixed_score', passValue: subject.fullScore * 0.6,
|
||||||
|
passScore: subject.fullScore * 0.6, order: index + 1
|
||||||
|
}));
|
||||||
|
const mainSubjectIds = mainSubjectDefinitions.map(subject => subject.id);
|
||||||
|
const mainCandidateCount = Math.max(1, Math.trunc(Number(candidateCount) || 1200));
|
||||||
|
const specialtyCandidateCount = Math.min(150, mainCandidateCount);
|
||||||
|
|
||||||
|
// 固定种子使每次导入得到相同的近似正态成绩,便于复现测试。
|
||||||
|
let randomState = 0x20260620;
|
||||||
|
const seededRandom = () => {
|
||||||
|
randomState = (randomState + 0x6D2B79F5) >>> 0;
|
||||||
|
let value = randomState;
|
||||||
|
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||||
|
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||||
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
const normalRandom = () => {
|
||||||
|
const first = Math.max(seededRandom(), Number.EPSILON);
|
||||||
|
return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * seededRandom());
|
||||||
|
};
|
||||||
|
const normalScore = fullScore => Number(Math.min(fullScore, Math.max(0, fullScore * 0.72 + fullScore * 0.14 * normalRandom())).toFixed(1));
|
||||||
|
const scoreGrade = (score, fullScore) => score >= fullScore * 0.9 ? 'A' : score >= fullScore * 0.75 ? 'B' : score >= fullScore * 0.6 ? 'C' : 'D';
|
||||||
const database = {
|
const database = {
|
||||||
meta: { version: 15, createdAt: nowIso() },
|
meta: { version: 15, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
@@ -16,8 +49,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
address: '江苏省连云港市海州区文教路 18 号'
|
address: '江苏省连云港市海州区文教路 18 号'
|
||||||
},
|
},
|
||||||
schools: [
|
schools: [
|
||||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
|
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', isSourceSchool: true, isAdmissionSchool: false, active: true },
|
||||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
|
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', isSourceSchool: true, isAdmissionSchool: false, active: true }
|
||||||
],
|
],
|
||||||
classes: [
|
classes: [
|
||||||
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||||
@@ -39,7 +72,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||||
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
|
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
|
||||||
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000',
|
||||||
|
specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyTypes: ['fine_arts'], specialtyCertificate: 'ART-DEMO-0001', policyEligibility: '特长生资格已核验', profileCompleted: true,
|
||||||
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -50,33 +84,29 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
],
|
],
|
||||||
exams: [
|
exams: [
|
||||||
{
|
{
|
||||||
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
|
id: examId, code: 'EX-2026-ZK', name: '2026 年海州市初中学业水平考试', description: '演示数据主考试:覆盖成绩发布、特长生和第一轮志愿填报。',
|
||||||
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
|
registrationStart: '2026-04-01T00:00:00.000Z', registrationEnd: '2026-04-30T15:59:59.000Z',
|
||||||
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
|
examStart: '2026-06-20T01:00:00.000Z', examEnd: '2026-06-22T08:00:00.000Z',
|
||||||
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
|
admitDownloadStart: '2026-06-10T00:00:00.000Z', admitDownloadEnd: '2026-06-20T00:45:00.000Z',
|
||||||
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
|
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-03-18T02:00:00.000Z',
|
||||||
subjects: [
|
subjects: mainSubjectDefinitions
|
||||||
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passRule: 'fixed_score', passValue: 90, passScore: 90 },
|
|
||||||
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passRule: 'rank_percent', passValue: 60, passScore: null },
|
|
||||||
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
|
||||||
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
|
||||||
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 },
|
|
||||||
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25, fullScore: 100, passScore: 60 },
|
|
||||||
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25, fullScore: 100, passScore: 60 }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
|
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
|
||||||
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
|
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
|
||||||
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
|
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
|
||||||
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
|
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
|
||||||
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: []
|
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [
|
||||||
|
{ id: 'mock_sub_chinese', name: '语文', date: '2026-11-08', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||||
|
{ id: 'mock_sub_math', name: '数学', date: '2026-11-08', start: '14:30', end: '16:30', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||||
|
{ id: 'mock_sub_english', name: '外语', date: '2026-11-09', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
registrations: [
|
registrations: [
|
||||||
{
|
{
|
||||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
id: registrationId, userId: candidateId, examId, subjectIds: mainSubjectIds,
|
||||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default'
|
status: 'approved', paymentStatus: 'paid', paidAt: '2026-05-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-04-08T05:18:00.000Z', reviewedAt: '2026-04-18T08:32:00.000Z', reviewNote: '报名审核通过', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', featureScore: 90
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
results: [],
|
results: [],
|
||||||
@@ -171,15 +201,21 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const schoolDefinitions = [
|
const sourceSchools = [
|
||||||
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
|
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
|
||||||
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
|
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
|
||||||
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
|
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
|
||||||
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' }
|
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' },
|
||||||
|
{ key: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' }
|
||||||
];
|
];
|
||||||
for (const school of schoolDefinitions) {
|
const admissionSchools = [
|
||||||
|
{ key: 'admission_1', id: 'school_admission_1', code: 'AD01', name: '海州市高级中学', address: '江苏省连云港市海州区苍梧路 100 号' },
|
||||||
|
{ key: 'admission_2', id: 'school_admission_2', code: 'AD02', name: '海州市实验高级中学', address: '江苏省连云港市连云区海棠路 88 号' },
|
||||||
|
{ key: 'admission_3', id: 'school_admission_3', code: 'AD03', name: '海州市外国语高级中学', address: '江苏省连云港市赣榆区黄海路 66 号' }
|
||||||
|
];
|
||||||
|
for (const school of sourceSchools) {
|
||||||
if (!database.schools.some(item => item.id === school.id)) {
|
if (!database.schools.some(item => item.id === school.id)) {
|
||||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, active: true });
|
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, isSourceSchool: true, isAdmissionSchool: false, active: true });
|
||||||
}
|
}
|
||||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||||
const classId = `class_${school.key}_30${classIndex}`;
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
@@ -189,14 +225,25 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const school of schoolDefinitions) {
|
for (const school of admissionSchools) {
|
||||||
|
database.schools.push({ id: school.id, name: school.name, code: school.code, address: school.address, isSourceSchool: false, isAdmissionSchool: true, active: true });
|
||||||
|
database.users.push({
|
||||||
|
id: `usr_${school.key}`, username: `${school.key}_admin`, passwordHash: testPasswordHash, role: 'admission_school',
|
||||||
|
schoolId: school.id, displayName: `${school.name}招生办`, active: true, mustChangePassword: false, createdAt: nowIso()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const school of sourceSchools) {
|
||||||
|
if (school.id !== 'school_hz1') {
|
||||||
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||||
database.users.push({
|
database.users.push({
|
||||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||||
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||||
});
|
});
|
||||||
|
}
|
||||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||||
const classId = `class_${school.key}_30${classIndex}`;
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
|
if (classId === 'class_hz1_302') continue;
|
||||||
database.users.push({
|
database.users.push({
|
||||||
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
|
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
|
||||||
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
||||||
@@ -205,7 +252,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const school of schoolDefinitions) {
|
for (const school of sourceSchools) {
|
||||||
let center = database.testCenters.find(item => item.schoolId === school.id);
|
let center = database.testCenters.find(item => item.schoolId === school.id);
|
||||||
if (!center) {
|
if (!center) {
|
||||||
center = {
|
center = {
|
||||||
@@ -228,21 +275,20 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试库停留在考场编排前:不预置编排计划、准考证或成绩。
|
// 测试库停留在考场编排前:不预置编排计划或准考证;主考试成绩与志愿已完成。
|
||||||
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
|
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
|
||||||
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
|
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
|
||||||
const subjectSets = [
|
const specialtyDefinitions = [
|
||||||
['sub_chinese', 'sub_math', 'sub_english'],
|
{ category: 'sports', type: 'track_field', label: '田径' },
|
||||||
['sub_chinese', 'sub_math', 'sub_physics', 'sub_chemistry'],
|
{ category: 'sports', type: 'basketball', label: '篮球' },
|
||||||
['sub_chinese', 'sub_math', 'sub_history', 'sub_biology'],
|
{ category: 'arts', type: 'fine_arts', label: '美术' },
|
||||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_physics', 'sub_chemistry'],
|
{ category: 'arts', type: 'vocal_music', label: '声乐' },
|
||||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_history', 'sub_biology']
|
{ category: 'arts', type: 'dance', label: '舞蹈' }
|
||||||
];
|
];
|
||||||
const registrationWorkflowId = 'workflow_registration';
|
for (let index = 0; index < mainCandidateCount - 1; index += 1) {
|
||||||
for (let index = 0; index < 360; index += 1) {
|
|
||||||
const serial = index + 1001;
|
const serial = index + 1001;
|
||||||
const school = schoolDefinitions[index % schoolDefinitions.length];
|
const school = sourceSchools[index % sourceSchools.length];
|
||||||
const classIndex = Math.floor(index / schoolDefinitions.length) % 3 + 1;
|
const classIndex = Math.floor(index / sourceSchools.length) % 3 + 1;
|
||||||
const classId = `class_${school.key}_30${classIndex}`;
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
const gender = index % 2 === 0 ? '男' : '女';
|
const gender = index % 2 === 0 ? '男' : '女';
|
||||||
const genderCode = gender === '男' ? 'M' : 'F';
|
const genderCode = gender === '男' ? 'M' : 'F';
|
||||||
@@ -250,7 +296,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
|
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
|
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
|
||||||
const createdAt = new Date(Date.UTC(2026, 6, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
const createdAt = new Date(Date.UTC(2026, 3, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||||
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
|
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
|
||||||
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
|
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
|
||||||
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
|
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
|
||||||
@@ -258,6 +304,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
|
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
|
||||||
displayName: name, active: true, mustChangePassword: false, createdAt
|
displayName: name, active: true, mustChangePassword: false, createdAt
|
||||||
});
|
});
|
||||||
|
const isSpecialtyCandidate = index < specialtyCandidateCount - 1;
|
||||||
|
const specialty = isSpecialtyCandidate ? specialtyDefinitions[index % specialtyDefinitions.length] : null;
|
||||||
database.candidateProfiles.push({
|
database.candidateProfiles.push({
|
||||||
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
|
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
|
||||||
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
|
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
|
||||||
@@ -266,41 +314,82 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||||
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
|
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
|
||||||
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
|
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
|
||||||
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`, profileCompleted: true, status: 'approved',
|
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||||
reviewNote: '批量测试数据:学籍核验通过', reviewedAt: '2026-06-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
specialtyCategory: specialty?.category || '', specialtyType: specialty?.type || '', specialtyTypes: specialty ? [specialty.type] : [],
|
||||||
|
specialtyCertificate: specialty ? `SPECIAL-2026-${String(index + 2).padStart(4, '0')}` : '', policyEligibility: specialty ? `${specialty.label}特长生资格已核验` : '',
|
||||||
|
profileCompleted: true, status: 'approved', reviewNote: '批量演示数据:学籍核验通过',
|
||||||
|
reviewedAt: '2026-04-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||||
});
|
});
|
||||||
|
|
||||||
const scenario = index % 4;
|
const paymentStatus = index % 2 === 0 ? 'paid' : 'unpaid';
|
||||||
const status = scenario === 0 ? 'pending' : scenario === 1 ? 'rejected' : 'approved';
|
const classAdminId = classId === 'class_hz1_302' ? 'usr_class_admin' : `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||||
const paymentStatus = scenario === 3 ? 'paid' : 'unpaid';
|
|
||||||
const classAdminId = `usr_test_class_admin_${school.key}_${classIndex}`;
|
|
||||||
const schoolAdmin = `usr_test_school_admin_${school.key}`;
|
|
||||||
database.registrations.push({
|
database.registrations.push({
|
||||||
id: registrationIdBulk, userId, examId, subjectIds: subjectSets[index % subjectSets.length], status, paymentStatus,
|
id: registrationIdBulk, userId, examId, subjectIds: mainSubjectIds, status: 'approved', paymentStatus,
|
||||||
paidAt: paymentStatus === 'paid' ? '2026-07-25T08:30:00.000Z' : null,
|
paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null,
|
||||||
paidBy: paymentStatus === 'paid' ? classAdminId : null,
|
paidBy: paymentStatus === 'paid' ? classAdminId : null,
|
||||||
createdAt, reviewedAt: status === 'pending' ? null : '2026-07-22T08:00:00.000Z',
|
createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过',
|
||||||
reviewNote: status === 'rejected' ? '测试场景:报名资料被退回' : status === 'approved' ? '测试场景:报名审核通过' : '',
|
registrationNumber: candidateNumber, numberRuleId: 'rule_default',
|
||||||
registrationNumber: candidateNumber, numberRuleId: 'rule_default'
|
featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0
|
||||||
});
|
|
||||||
if (status === 'pending' || status === 'rejected') {
|
|
||||||
const instanceId = `flow_reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
|
||||||
database.workflowInstances.push({
|
|
||||||
id: instanceId, workflowId: registrationWorkflowId, businessType: 'registration_review', businessId: registrationIdBulk,
|
|
||||||
status, currentStep: 1, assigneeId: status === 'pending' ? schoolAdmin : null, createdAt,
|
|
||||||
completedAt: status === 'rejected' ? '2026-07-22T08:00:00.000Z' : null
|
|
||||||
});
|
|
||||||
database.workflowActions.push({
|
|
||||||
id: `flow_action_submit_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: userId, action: 'submit',
|
|
||||||
note: '提交考试报名', fromAssigneeId: null, toAssigneeId: schoolAdmin, createdAt
|
|
||||||
});
|
|
||||||
if (status === 'rejected') {
|
|
||||||
database.workflowActions.push({
|
|
||||||
id: `flow_action_reject_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: schoolAdmin, action: 'reject',
|
|
||||||
note: '测试场景:报名资料被退回', fromAssigneeId: schoolAdmin, toAssigneeId: null, createdAt: '2026-07-22T08:00:00.000Z'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const admissionCreatedAt = '2026-07-01T00:00:00.000Z';
|
||||||
|
database.admissionRecords.push({
|
||||||
|
id: 'admission_setting_main_2026', kind: 'setting', examId, userId: adminId, schoolId: null, status: 'closed',
|
||||||
|
payload: {
|
||||||
|
enabled: true, preferenceStart: '2026-07-01T00:00:00.000Z', preferenceEnd: '2026-07-15T15:59:59.000Z',
|
||||||
|
maxChoices: 3, maxSubmissions: 1, round: 1, autoPublish: true, progress: '第一轮志愿已全部填报完毕,等待投档'
|
||||||
|
},
|
||||||
|
createdAt: admissionCreatedAt, updatedAt: '2026-07-16T00:00:00.000Z'
|
||||||
|
});
|
||||||
|
for (const school of admissionSchools) {
|
||||||
|
database.admissionRecords.push({
|
||||||
|
id: `admission_plan_${school.key}`, kind: 'plan', examId, userId: `usr_${school.key}`, schoolId: school.id, status: 'approved',
|
||||||
|
payload: {
|
||||||
|
categories: [
|
||||||
|
{ code: 'general', name: '普通生', quota: 350, specialtyCategory: '', specialtyType: '', indicatorAllocations: [] },
|
||||||
|
{ code: 'sports', name: '体育特长生', quota: 1, specialtyCategory: 'sports', specialtyType: '', indicatorAllocations: [] },
|
||||||
|
{ code: 'arts', name: '艺术特长生', quota: 1, specialtyCategory: 'arts', specialtyType: '', indicatorAllocations: [] }
|
||||||
|
],
|
||||||
|
note: '演示数据招生计划:普通类 350 人,特长生合计 2 人', submittedBy: `${school.name}招生办`, reviewedBy: '林老师', reviewedAt: admissionCreatedAt, publicVisible: true
|
||||||
|
},
|
||||||
|
createdAt: admissionCreatedAt, updatedAt: admissionCreatedAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mainRegistrations = database.registrations.filter(registration => registration.examId === examId);
|
||||||
|
for (const [candidateIndex, registration] of mainRegistrations.entries()) {
|
||||||
|
const profile = database.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
const isSpecialtyCandidate = Boolean(profile?.specialtyCategory);
|
||||||
|
for (const subject of mainSubjectDefinitions) {
|
||||||
|
const score = normalScore(subject.fullScore);
|
||||||
|
database.results.push({
|
||||||
|
id: `result_main_${String(candidateIndex + 1).padStart(4, '0')}_${subject.id.slice(4)}`,
|
||||||
|
registrationId: registration.id, subjectId: subject.id, score, grade: scoreGrade(score, subject.fullScore),
|
||||||
|
published: true, updatedAt: '2026-06-30T08:00:00.000Z', publishedAt: '2026-06-30T08:00:00.000Z'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rotatedAdmissionSchools = admissionSchools.map((_, offset) => admissionSchools[(candidateIndex + offset) % admissionSchools.length]);
|
||||||
|
const choices = rotatedAdmissionSchools.map((school, choiceIndex) => ({
|
||||||
|
schoolId: school.id,
|
||||||
|
categoryCode: isSpecialtyCandidate && choiceIndex === 0 ? profile.specialtyCategory : 'general',
|
||||||
|
preferenceType: 'general'
|
||||||
|
}));
|
||||||
|
database.admissionRecords.push({
|
||||||
|
id: `preference_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'preference', examId,
|
||||||
|
userId: registration.userId, schoolId: null, status: 'submitted',
|
||||||
|
payload: {
|
||||||
|
round: 1, submissionCount: 1, submittedAt: new Date(Date.UTC(2026, 6, 5 + (candidateIndex % 10), 1 + (candidateIndex % 8), candidateIndex % 60)).toISOString(),
|
||||||
|
choices
|
||||||
|
},
|
||||||
|
createdAt: admissionCreatedAt, updatedAt: '2026-07-15T08:00:00.000Z'
|
||||||
|
});
|
||||||
|
database.admissionRecords.push({
|
||||||
|
id: `qualification_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'indicator_qualification', examId,
|
||||||
|
userId: registration.userId, schoolId: profile.schoolId, status: 'confirmed',
|
||||||
|
payload: { eligible: isSpecialtyCandidate, confirmedAt: '2026-06-28T08:00:00.000Z', note: isSpecialtyCandidate ? '特长资格核验通过' : '普通生' },
|
||||||
|
createdAt: '2026-06-28T08:00:00.000Z', updatedAt: '2026-06-28T08:00:00.000Z'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return database;
|
return database;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const CURRENT_SCHEMA_VERSION = 20;
|
||||||
+497
-56
@@ -1,7 +1,8 @@
|
|||||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||||
import { admissionCutoffRows, admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
import { activePreference, admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||||
|
|
||||||
export function createAdminRoutes(context) {
|
export function createAdminRoutes(context) {
|
||||||
const {
|
const {
|
||||||
@@ -110,6 +111,132 @@ export function createAdminRoutes(context) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function admissionChoiceView(db, examId, choice) {
|
||||||
|
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||||
|
const categories = admissionRecords(db, 'plan', examId)
|
||||||
|
.filter(plan => plan.schoolId === choice.schoolId)
|
||||||
|
.flatMap(plan => plan.payload?.categories || []);
|
||||||
|
const category = categories.find(item => item.code === choice.categoryCode)
|
||||||
|
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||||
|
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||||
|
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||||
|
return {
|
||||||
|
...choice,
|
||||||
|
schoolCode: choice.schoolCode || school?.code || '',
|
||||||
|
schoolName: choice.schoolName || school?.name || '',
|
||||||
|
categoryName: choice.categoryName || category?.name || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissionPreferenceSnapshotRows(db) {
|
||||||
|
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||||
|
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||||
|
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||||
|
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||||
|
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||||
|
const statusLabel = { unfilled: '尚未填报', submitted: '已填报', locked: '已锁定', unavailable: '成绩未齐', ineligible: '不可补录' };
|
||||||
|
const rows = [];
|
||||||
|
for (const setting of admissionRecords(db, 'setting').filter(item => item.payload?.enabled)) {
|
||||||
|
const exam = examById.get(setting.examId) || {};
|
||||||
|
const round = Number(setting.payload?.round || 1);
|
||||||
|
const maxSubmissions = Number(setting.payload?.maxSubmissions || 3);
|
||||||
|
const registrations = db.registrations.filter(item => item.examId === setting.examId && item.status === 'approved');
|
||||||
|
for (const registration of registrations) {
|
||||||
|
const account = accountById.get(registration.userId);
|
||||||
|
const profile = profileByUserId.get(registration.userId);
|
||||||
|
if (!account?.active || account.role !== 'candidate' || !profile) continue;
|
||||||
|
const preference = activePreference(db, setting.examId, registration.userId, round);
|
||||||
|
const blockingPlacement = setting.status === 'supplementary'
|
||||||
|
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === registration.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||||
|
: null;
|
||||||
|
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||||
|
const status = blockingPlacement ? 'ineligible'
|
||||||
|
: preference && submissionCount >= maxSubmissions ? 'locked'
|
||||||
|
: preference ? 'submitted'
|
||||||
|
: candidateTotalScore(db, setting.examId, registration.userId) == null ? 'unavailable' : 'unfilled';
|
||||||
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
|
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||||
|
const schoolClass = classById.get(profile.classId) || {};
|
||||||
|
const indicator = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === registration.userId && item.status === 'confirmed');
|
||||||
|
rows.push({
|
||||||
|
id: `${setting.examId}:${round}:${registration.userId}`,
|
||||||
|
examId: setting.examId,
|
||||||
|
examCode: exam.code || '',
|
||||||
|
examName: exam.name || '',
|
||||||
|
round,
|
||||||
|
phase: setting.status,
|
||||||
|
status,
|
||||||
|
fillStatus: statusLabel[status],
|
||||||
|
lockStatus: status === 'locked' ? '已锁定' : status === 'ineligible' ? '不可填报' : '未锁定',
|
||||||
|
submissionCount,
|
||||||
|
maxSubmissions,
|
||||||
|
submittedAt: preference?.payload?.submittedAt || preference?.updatedAt || '',
|
||||||
|
sourceSchoolId: sourceSchool.id || '',
|
||||||
|
sourceSchoolCode: sourceSchool.code || '',
|
||||||
|
sourceSchoolName: sourceSchool.name || '',
|
||||||
|
className: schoolClass.name || profile.className || '',
|
||||||
|
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生',
|
||||||
|
indicatorStatus: indicator ? (indicator.payload?.eligible ? '有资格' : '无资格') : '未确认',
|
||||||
|
candidate: { userId: registration.userId, registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '' },
|
||||||
|
choices: (preference?.payload?.choices || []).map(choice => admissionChoiceView(db, setting.examId, choice))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.sort((left, right) => left.examName.localeCompare(right.examName, 'zh-CN') || left.candidate.registrationNumber.localeCompare(right.candidate.registrationNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissionPlacementLedgerRows(db) {
|
||||||
|
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||||
|
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||||
|
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||||
|
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||||
|
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||||
|
const reportingByPlacement = new Map();
|
||||||
|
const reportingRecords = admissionRecords(db, 'notification')
|
||||||
|
.filter(item => item.payload?.type === 'admission_reporting')
|
||||||
|
.sort((left, right) => new Date(left.updatedAt || left.createdAt) - new Date(right.updatedAt || right.createdAt));
|
||||||
|
for (const record of reportingRecords) for (const row of record.payload?.rows || []) reportingByPlacement.set(row.placementId, row);
|
||||||
|
const admissionStatusLabels = { school_review: '学校审核中', admitted: '拟录取', withdrawal_pending: '退档待审', final: '正式录取', withdrawn: '已退档', forfeited: '未报到放弃' };
|
||||||
|
const reportingStatusLabels = { reported: '已报到', not_reported: '未报到', pending: '待确认' };
|
||||||
|
return admissionRecords(db, 'placement').map(placement => {
|
||||||
|
const exam = examById.get(placement.examId) || {};
|
||||||
|
const school = schoolById.get(placement.schoolId) || {};
|
||||||
|
const account = accountById.get(placement.userId) || {};
|
||||||
|
const profile = profileByUserId.get(placement.userId) || {};
|
||||||
|
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||||
|
const schoolClass = classById.get(profile.classId) || {};
|
||||||
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
|
const reporting = reportingByPlacement.get(placement.id);
|
||||||
|
return {
|
||||||
|
...placement,
|
||||||
|
examName: exam.name || '', examCode: exam.code || '',
|
||||||
|
schoolName: school.name || '', schoolCode: school.code || '',
|
||||||
|
sourceSchoolId: sourceSchool.id || '', sourceSchoolName: sourceSchool.name || '', sourceSchoolCode: sourceSchool.code || '',
|
||||||
|
className: schoolClass.name || profile.className || '',
|
||||||
|
candidate: { registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '', idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) || '普通生' },
|
||||||
|
reportingStatus: reporting?.status || (placement.status === 'final' ? 'pending' : ''),
|
||||||
|
reportingStatusLabel: reportingStatusLabels[reporting?.status] || (placement.status === 'final' ? '待确认' : '—'),
|
||||||
|
admissionStatusLabel: admissionStatusLabels[placement.status] || placement.status
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAdmissionLedgerRows(rows, searchParams) {
|
||||||
|
const query = String(searchParams.get('q') || '').trim().toLowerCase();
|
||||||
|
const filters = {
|
||||||
|
examId: searchParams.get('examId') || '',
|
||||||
|
schoolId: searchParams.get('schoolId') || '',
|
||||||
|
sourceSchoolId: searchParams.get('sourceSchoolId') || '',
|
||||||
|
status: searchParams.get('status') || '',
|
||||||
|
round: searchParams.get('round') || ''
|
||||||
|
};
|
||||||
|
return rows.filter(item => {
|
||||||
|
const haystack = JSON.stringify(item).toLowerCase();
|
||||||
|
return Object.entries(filters).every(([key, value]) => !value || String(item[key] ?? item.payload?.[key] ?? '') === value)
|
||||||
|
&& (!query || query.split(/\s+/).every(word => haystack.includes(word)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAdmissionCategories(input) {
|
function normalizeAdmissionCategories(input) {
|
||||||
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
||||||
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
||||||
@@ -120,11 +247,81 @@ export function createAdminRoutes(context) {
|
|||||||
})).filter(item => item.code && item.name && item.quota > 0);
|
})).filter(item => item.code && item.name && item.quota > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function systemPublications(db) {
|
||||||
|
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
|
||||||
|
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
|
||||||
|
const view = (record, sourceType, category, title, publishedAt, summary) => ({
|
||||||
|
id: record.id,
|
||||||
|
sourceType,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
author: '系统自动发布',
|
||||||
|
publishedAt,
|
||||||
|
visible: record.payload?.publicVisible !== false,
|
||||||
|
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
|
||||||
|
});
|
||||||
|
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => view(
|
||||||
|
item,
|
||||||
|
'plan',
|
||||||
|
'招生计划',
|
||||||
|
`${examName(item.examId)} · ${schoolName(item.schoolId)}招生计划公示`,
|
||||||
|
item.payload?.reviewedAt || item.updatedAt,
|
||||||
|
'审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。'
|
||||||
|
));
|
||||||
|
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published').map(item => view(
|
||||||
|
item,
|
||||||
|
'qualification',
|
||||||
|
'指标资格',
|
||||||
|
`${examName(item.examId)} · ${schoolName(item.schoolId)}指标分配资格公示`,
|
||||||
|
item.payload?.publishedAt || item.updatedAt,
|
||||||
|
'资格确认完成后由系统生成,内容随资格确认结果更新。'
|
||||||
|
));
|
||||||
|
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
|
||||||
|
{ ...item, id: item.sourceRecordId || item.id },
|
||||||
|
'admission',
|
||||||
|
'录取名单',
|
||||||
|
`${examName(item.examId)}第 ${item.round} 轮录取名单公示`,
|
||||||
|
item.publishedAt,
|
||||||
|
`第 ${item.round} 轮录取通知书签发后由系统自动生成,共 ${item.rows.length} 人。`
|
||||||
|
));
|
||||||
|
const virtualSourceIds = new Set(admissionRoundPublications(db).filter(item => item.virtual).map(item => item.sourceRecordId));
|
||||||
|
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && !virtualSourceIds.has(item.id)).map(item => view(
|
||||||
|
item,
|
||||||
|
'admission',
|
||||||
|
'录取名单',
|
||||||
|
`${examName(item.examId)}最终录取名单`,
|
||||||
|
item.payload?.completedAt || item.updatedAt,
|
||||||
|
'录取结束后由系统生成,内容取自最终录取结果。'
|
||||||
|
));
|
||||||
|
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
|
||||||
|
item,
|
||||||
|
'cutoff',
|
||||||
|
'录取分数线',
|
||||||
|
`${examName(item.examId)}录取分数线`,
|
||||||
|
item.payload?.publishedAt || item.updatedAt,
|
||||||
|
'录取结束后由系统生成,内容取自各招生类别最低录取分数。'
|
||||||
|
));
|
||||||
|
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting').map(item => ({
|
||||||
|
id: item.id, sourceType: 'reporting', category: item.category, title: item.title, summary: item.summary,
|
||||||
|
author: item.author, publishedAt: item.publishAt, visible: item.visible, status: item.status
|
||||||
|
}));
|
||||||
|
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
|
||||||
|
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdmin(request, response, pathname) {
|
async function handleAdmin(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/admin/')) return false;
|
if (!pathname.startsWith('/api/admin/')) return false;
|
||||||
const user = await requireUser(request, response, 'admin');
|
const user = await requireUser(request, response, 'admin');
|
||||||
if (!user) return true;
|
if (!user) return true;
|
||||||
const db = await readDb();
|
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||||
|
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||||
|
if (!requestedExamId) {
|
||||||
|
if (!requirePermission(user, response, 'results.read')) return true;
|
||||||
|
return sendJson(response, 200, { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: [], resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const db = request.authDb || await readDb();
|
||||||
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
|
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
|
||||||
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
|
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
|
||||||
}
|
}
|
||||||
@@ -176,7 +373,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (status.complete) {
|
if (status.complete) {
|
||||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
|
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||||
records.push(publication);
|
records.push(publication);
|
||||||
}
|
}
|
||||||
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||||
@@ -203,30 +400,90 @@ export function createAdminRoutes(context) {
|
|||||||
if (status.complete) {
|
if (status.complete) {
|
||||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
|
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||||
records.push(publication);
|
records.push(publication);
|
||||||
}
|
}
|
||||||
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||||
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
|
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const admissionLedgerExportMatch = pathname.match(/^\/api\/admin\/admissions\/(preferences|placements)\/export$/);
|
||||||
|
if (admissionLedgerExportMatch && request.method === 'GET') {
|
||||||
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以导出志愿与录取台账');
|
||||||
|
const searchParams = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams;
|
||||||
|
const kind = admissionLedgerExportMatch[1];
|
||||||
|
const selectedExam = db.exams.find(item => item.id === searchParams.get('examId'));
|
||||||
|
const subtitle = `${selectedExam?.name || '全部考试'}|按当前筛选条件导出|生成时间 ${new Date().toLocaleString('zh-CN', { hour12: false })}`;
|
||||||
|
if (kind === 'preferences') {
|
||||||
|
const snapshots = filterAdmissionLedgerRows(admissionPreferenceSnapshotRows(db), searchParams);
|
||||||
|
const rows = snapshots.flatMap(item => {
|
||||||
|
const choices = item.choices.length ? item.choices : [null];
|
||||||
|
return choices.map((choice, index) => ({
|
||||||
|
examCode: item.examCode, examName: item.examName, round: item.round,
|
||||||
|
fillStatus: item.fillStatus, lockStatus: item.lockStatus,
|
||||||
|
submissionCount: item.submissionCount, maxSubmissions: item.maxSubmissions,
|
||||||
|
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||||
|
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||||
|
specialty: item.specialty, indicatorStatus: item.indicatorStatus,
|
||||||
|
preferenceOrder: choice ? Number(choice.order || index + 1) : '',
|
||||||
|
preferenceType: choice ? (choice.preferenceType === 'indicator' ? '指标志愿' : '普通志愿') : '',
|
||||||
|
targetSchoolCode: choice?.schoolCode || '', targetSchoolName: choice?.schoolName || '', categoryName: choice?.categoryName || choice?.categoryCode || '',
|
||||||
|
submittedAt: item.submittedAt
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(await buildWorkbook('admission_preferences', rows, { subtitle }));
|
||||||
|
return sendWorkbook(response, buffer, `志愿填报实时台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
|
}
|
||||||
|
const placements = filterAdmissionLedgerRows(admissionPlacementLedgerRows(db), searchParams);
|
||||||
|
const rows = placements.map(item => ({
|
||||||
|
examCode: item.examCode, examName: item.examName, round: Number(item.payload?.round || 1),
|
||||||
|
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||||
|
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||||
|
specialty: item.candidate.specialtyLabel,
|
||||||
|
culturalScore: Number(item.payload?.culturalScore ?? item.payload?.totalScore ?? 0),
|
||||||
|
featureScore: Number(item.payload?.featureScore || 0), totalScore: Number(item.payload?.totalScore || 0),
|
||||||
|
preferenceOrder: Number(item.payload?.preferenceOrder || 0),
|
||||||
|
admissionSchoolCode: item.schoolCode, admissionSchoolName: item.schoolName,
|
||||||
|
categoryName: item.payload?.categoryName || '',
|
||||||
|
quotaBucket: String(item.payload?.quotaBucket || '').startsWith('indicator') ? '指标分配' : '普通计划',
|
||||||
|
admissionStatus: item.admissionStatusLabel, reportingStatus: item.reportingStatusLabel,
|
||||||
|
noticeNumber: item.payload?.noticeNumber || '', withdrawalReason: item.payload?.withdrawalReason || item.payload?.reportingNote || '',
|
||||||
|
updatedAt: item.updatedAt || item.payload?.finalizedAt || ''
|
||||||
|
}));
|
||||||
|
const buffer = Buffer.from(await buildWorkbook('admission_placements', rows, { subtitle }));
|
||||||
|
return sendWorkbook(response, buffer, `招生录取情况台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
||||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
|
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) }));
|
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||||
const placements = admissionRecords(db, 'placement').map(placement => {
|
const userById = new Map(db.users.map(item => [item.id, item]));
|
||||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) }));
|
||||||
const qualification = resolveProfileSpecialty(profile);
|
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||||
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' };
|
const placements = admissionPlacementLedgerRows(db);
|
||||||
});
|
|
||||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
const account = userById.get(preference.userId) || {};
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
const profile = profileByUserId.get(preference.userId) || {};
|
||||||
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) };
|
const plansForExam = admissionRecords(db, 'plan', preference.examId);
|
||||||
|
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => {
|
||||||
|
const school = schoolById.get(choice.schoolId);
|
||||||
|
const categories = plansForExam.filter(plan => plan.schoolId === choice.schoolId).flatMap(plan => plan.payload?.categories || []);
|
||||||
|
const category = categories.find(item => item.code === choice.categoryCode)
|
||||||
|
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||||
|
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||||
|
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||||
|
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||||
|
}) };
|
||||||
});
|
});
|
||||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser);
|
const preferenceRows = admissionPreferenceSnapshotRows(db);
|
||||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
|
||||||
|
const school = schoolById.get(item.schoolId);
|
||||||
|
return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' };
|
||||||
|
});
|
||||||
|
const reportingRequests = admissionRecords(db, 'notification').filter(item => item.userId == null && item.payload?.type === 'admission_reporting').map(item => ({ ...item, schoolName: schoolById.get(item.schoolId)?.name || '', examName: examById.get(item.examId)?.name || '', progress: admissionPlanProgress(db, admissionRecords(db, 'plan', item.examId).find(plan => plan.schoolId === item.schoolId) || { examId: item.examId, schoolId: item.schoolId, payload: { categories: [] } }) }));
|
||||||
|
return sendJson(response, 200, { ok: true, settings, plans, preferences, preferenceRows, placements, reportingRequests, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
||||||
@@ -240,6 +497,30 @@ export function createAdminRoutes(context) {
|
|||||||
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
|
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
|
||||||
return sendJson(response, 201, { ok: true, account: safeUser(account) });
|
return sendJson(response, 201, { ok: true, account: safeUser(account) });
|
||||||
}
|
}
|
||||||
|
const admissionAccountMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)$/);
|
||||||
|
if (admissionAccountMatch && request.method === 'PATCH') {
|
||||||
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护招生学校账号');
|
||||||
|
const target = db.users.find(item => item.id === admissionAccountMatch[1] && item.role === 'admission_school');
|
||||||
|
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||||
|
const body = await readJson(request);
|
||||||
|
target.active = body.active == null ? target.active : Boolean(body.active);
|
||||||
|
target.displayName = cleanText(body.displayName || target.displayName, 80);
|
||||||
|
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
|
||||||
|
if (!target.active) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||||
|
return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
|
||||||
|
}
|
||||||
|
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
|
||||||
|
if (admissionAccountResetMatch && request.method === 'POST') {
|
||||||
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置招生学校账号密码');
|
||||||
|
const target = db.users.find(item => item.id === admissionAccountResetMatch[1] && item.role === 'admission_school');
|
||||||
|
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||||
|
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
|
||||||
|
target.passwordHash = hashPassword(temporaryPassword);
|
||||||
|
target.active = true;
|
||||||
|
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
|
||||||
|
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||||
|
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
|
||||||
|
}
|
||||||
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
|
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
|
||||||
if (settingMatch && request.method === 'PUT') {
|
if (settingMatch && request.method === 'PUT') {
|
||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
|
||||||
@@ -274,8 +555,9 @@ export function createAdminRoutes(context) {
|
|||||||
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
|
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
|
||||||
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
|
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { ...plan.payload, categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
|
||||||
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
|
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||||
}
|
}
|
||||||
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
|
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
|
||||||
@@ -289,6 +571,7 @@ export function createAdminRoutes(context) {
|
|||||||
plan.updatedAt = nowIso();
|
plan.updatedAt = nowIso();
|
||||||
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
|
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
|
||||||
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
|
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, 200, { ok: true, plan });
|
return sendJson(response, 200, { ok: true, plan });
|
||||||
}
|
}
|
||||||
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
|
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
|
||||||
@@ -305,20 +588,32 @@ export function createAdminRoutes(context) {
|
|||||||
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
|
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
|
||||||
}
|
}
|
||||||
if (action === 'finalize') {
|
if (action === 'finalize') {
|
||||||
|
if (setting.status !== 'school_review') return sendError(response, 409, '只有招生学校审核阶段可以签发录取通知书并开启报到');
|
||||||
const placements = admissionRecords(db, 'placement', setting.examId);
|
const placements = admissionRecords(db, 'placement', setting.examId);
|
||||||
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
|
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now }));
|
const round = Number(setting.payload?.round || 1);
|
||||||
|
const admitted = assignAdmissionNoticeNumbers(db, placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now, payload: { ...item.payload, finalizedRound: round } })));
|
||||||
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
|
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
|
||||||
setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果与分数线已经自动公告'; setting.payload.completedAt = now;
|
const reportingRecords = approvedPlans(db, setting.examId).map(plan => {
|
||||||
const completedDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] };
|
const existing = admissionReportingRecord(db, setting.examId, plan.schoolId, round);
|
||||||
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
|
const schoolPlacements = admitted.filter(item => item.schoolId === plan.schoolId);
|
||||||
const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0];
|
const previousRows = existing?.payload?.rows || [];
|
||||||
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
|
const previousIds = new Set(previousRows.map(item => item.placementId));
|
||||||
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: cutoffRows } });
|
const rows = [...previousRows, ...schoolPlacements.filter(item => !previousIds.has(item.id)).map(item => ({ placementId: item.id, status: 'pending', note: '', updatedAt: now, source: 'system' }))];
|
||||||
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length} 人`));
|
const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now };
|
||||||
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows });
|
return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } };
|
||||||
|
});
|
||||||
|
const publicationDb = { ...db, admissionRecords: db.admissionRecords.map(item => admitted.find(entry => entry.id === item.id) || item) };
|
||||||
|
const existingPublication = admissionRecords(db, 'notification', setting.examId).find(item => item.userId == null && item.payload?.type === 'admission_round_publication' && Number(item.payload?.round || 1) === round);
|
||||||
|
const admissionPublication = existingPublication || { id: uid('admission_round_publication'), kind: 'notification', examId: setting.examId, userId: null, schoolId: null, createdAt: now };
|
||||||
|
Object.assign(admissionPublication, { status: 'published', updatedAt: now, payload: { ...admissionPublication.payload, type: 'admission_round_publication', round, publishedAt: now, publishedBy: user.displayName, rows: publicAdmissionRows(publicationDb, setting.examId, { round }) } });
|
||||||
|
setting.status = 'reporting'; setting.updatedAt = now; setting.payload = { ...setting.payload, roundPublishedAt: now, progress: `第 ${round} 轮录取结束,${admitted.length} 名考生已签发通知书,录取名单已公示,招生学校正在登记报到` };
|
||||||
|
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords, admissionPublication], logAction(db, user, '签发录取通知书并公示本轮录取名单', `${setting.examId} · 第 ${round} 轮 · ${admitted.length} 人`));
|
||||||
|
await cache.invalidate('public');
|
||||||
|
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length, publicationId: admissionPublication.id });
|
||||||
}
|
}
|
||||||
|
if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启');
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录');
|
if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录');
|
||||||
@@ -327,6 +622,67 @@ export function createAdminRoutes(context) {
|
|||||||
return sendJson(response, 200, { ok: true, setting });
|
return sendJson(response, 200, { ok: true, setting });
|
||||||
}
|
}
|
||||||
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
|
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
|
||||||
|
const reportingReviewMatch = pathname.match(/^\/api\/admin\/admission-reporting\/([^/]+)$/);
|
||||||
|
if (reportingReviewMatch && request.method === 'PATCH') {
|
||||||
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审批学校报到与补录决定');
|
||||||
|
const record = admissionRecords(db, 'notification').find(item => item.id === reportingReviewMatch[1] && item.userId == null && item.payload?.type === 'admission_reporting');
|
||||||
|
if (!record || record.status !== 'pending_approval') return sendError(response, 404, '待审批的报到与补录决定不存在');
|
||||||
|
const body = await readJson(request);
|
||||||
|
const approvalNote = cleanText(body.approvalNote, 500);
|
||||||
|
const now = nowIso();
|
||||||
|
if (body.approved !== true) {
|
||||||
|
record.status = 'rejected'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, rejectedAt: now, rejectedBy: user.displayName };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, '退回报到与补录决定', `${record.schoolId} · 第 ${record.payload?.round || 1} 轮`));
|
||||||
|
return sendJson(response, 200, { ok: true, record });
|
||||||
|
}
|
||||||
|
const supplement = record.payload?.supplementDecision === 'supplement';
|
||||||
|
const preferenceEnd = cleanText(body.preferenceEnd, 35);
|
||||||
|
if (supplement && (!preferenceEnd || new Date(preferenceEnd).getTime() <= Date.now())) return sendError(response, 400, '批准补录时必须设置晚于当前时间的补录志愿截止时间');
|
||||||
|
record.status = 'approved'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, approvedAt: now, approvedBy: user.displayName, approvedPreferenceEnd: supplement ? preferenceEnd : '' };
|
||||||
|
const reportPlan = admissionRecords(db, 'plan', record.examId).find(item => item.schoolId === record.schoolId) || { examId: record.examId, schoolId: record.schoolId, payload: { categories: [] } };
|
||||||
|
const reportDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||||
|
record.payload.statistics = admissionPlanProgress(reportDb, reportPlan);
|
||||||
|
const changedPlacements = [];
|
||||||
|
if (supplement) {
|
||||||
|
const notReported = new Set((record.payload?.rows || []).filter(item => item.status === 'not_reported').map(item => item.placementId));
|
||||||
|
for (const placement of admissionRecords(db, 'placement', record.examId).filter(item => notReported.has(item.id) && item.schoolId === record.schoolId && item.status === 'final')) {
|
||||||
|
changedPlacements.push({ ...placement, status: 'forfeited', updatedAt: now, payload: { ...placement.payload, forfeitedAt: now, forfeitedReason: '未按规定完成报到,学校补录申请已获批准' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const replacements = new Map([[record.id, record], ...changedPlacements.map(item => [item.id, item])]);
|
||||||
|
let nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => replacements.get(item.id) || item) };
|
||||||
|
replacements.set(record.id, record);
|
||||||
|
nextDb = { ...nextDb, admissionRecords: nextDb.admissionRecords.map(item => replacements.get(item.id) || item) };
|
||||||
|
const setting = admissionSetting(nextDb, record.examId);
|
||||||
|
const round = Number(record.payload?.round || 1);
|
||||||
|
const plans = approvedPlans(nextDb, record.examId);
|
||||||
|
const currentRecords = plans.map(item => admissionReportingRecord(nextDb, record.examId, item.schoolId, round));
|
||||||
|
const allApproved = currentRecords.length > 0 && currentRecords.every(item => item?.status === 'approved');
|
||||||
|
const recordsToSave = [record, ...changedPlacements];
|
||||||
|
let completed = false;
|
||||||
|
if (allApproved && setting) {
|
||||||
|
const supplementRecords = currentRecords.filter(item => item.payload?.supplementDecision === 'supplement');
|
||||||
|
setting.updatedAt = now;
|
||||||
|
if (supplementRecords.length) {
|
||||||
|
const supplementEnd = supplementRecords.map(item => item.payload?.approvedPreferenceEnd).filter(Boolean).sort().at(-1);
|
||||||
|
setting.status = 'supplementary';
|
||||||
|
setting.payload = { ...setting.payload, round: round + 1, preferenceStart: now, preferenceEnd: supplementEnd, progress: `第 ${round + 1} 轮补录志愿填报进行中,截止 ${new Date(supplementEnd).toLocaleString('zh-CN')}` };
|
||||||
|
} else {
|
||||||
|
setting.status = 'completed';
|
||||||
|
setting.payload = { ...setting.payload, completedAt: now, progress: '全部招生学校报到情况与补录决定已审批,录取工作完成' };
|
||||||
|
const cutoffRows = admissionCutoffRows(nextDb, setting.examId);
|
||||||
|
const existingCutoff = admissionRecords(nextDb, 'cutoff_publication', setting.examId)[0];
|
||||||
|
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
|
||||||
|
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { ...cutoffPublication.payload, publishedAt: now, rows: cutoffRows } });
|
||||||
|
recordsToSave.push(cutoffPublication);
|
||||||
|
completed = true;
|
||||||
|
}
|
||||||
|
recordsToSave.push(setting);
|
||||||
|
}
|
||||||
|
await database.saveAdmissionRecords(recordsToSave, logAction(db, user, supplement ? '批准补录申请并公开报到情况' : '批准不补录决定并公开报到情况', `${record.schoolId} · 第 ${round} 轮`));
|
||||||
|
await cache.invalidate('public');
|
||||||
|
return sendJson(response, 200, { ok: true, record, forfeitedCount: changedPlacements.length, phase: setting?.status, completed });
|
||||||
|
}
|
||||||
if (withdrawalMatch && request.method === 'PATCH') {
|
if (withdrawalMatch && request.method === 'PATCH') {
|
||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
|
||||||
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
|
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
|
||||||
@@ -350,7 +706,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
||||||
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||||
const template = requestUrl.searchParams.get('template') === '1';
|
const template = requestUrl.searchParams.get('template') === '1';
|
||||||
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
const rows = template && resource !== 'results' ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||||
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
||||||
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
@@ -861,6 +1217,10 @@ export function createAdminRoutes(context) {
|
|||||||
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
||||||
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
const account = db.users.find(item => item.id === profile.userId);
|
const account = db.users.find(item => item.id === profile.userId);
|
||||||
|
const registrations = db.registrations
|
||||||
|
.filter(item => item.userId === profile.userId)
|
||||||
|
.map(item => examRegistrationView(db, item))
|
||||||
|
.sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0));
|
||||||
return {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
|
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
|
||||||
@@ -870,6 +1230,7 @@ export function createAdminRoutes(context) {
|
|||||||
accountArchived: Boolean(account?.archivedAt),
|
accountArchived: Boolean(account?.archivedAt),
|
||||||
archivedAt: account?.archivedAt || null,
|
archivedAt: account?.archivedAt || null,
|
||||||
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
|
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
|
||||||
|
registrations,
|
||||||
workflow: workflowView(db, instance)
|
workflow: workflowView(db, instance)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1293,7 +1654,7 @@ export function createAdminRoutes(context) {
|
|||||||
const notices = db.notices
|
const notices = db.notices
|
||||||
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
||||||
.map(noticeForClient);
|
.map(noticeForClient);
|
||||||
return sendJson(response, 200, { ok: true, notices });
|
return sendJson(response, 200, { ok: true, notices, publications: systemPublications(db) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
@@ -1305,8 +1666,26 @@ export function createAdminRoutes(context) {
|
|||||||
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
|
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
|
||||||
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||||
await database.createNotice(notice, log);
|
await database.createNotice(notice, log);
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
||||||
}
|
}
|
||||||
|
const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff|reporting)\/([^/]+)$/);
|
||||||
|
if (request.method === 'PATCH' && publicationMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' };
|
||||||
|
const sourceType = publicationMatch[1];
|
||||||
|
const record = sourceType === 'admission'
|
||||||
|
? (db.admissionRecords || []).find(item => item.id === publicationMatch[2] && (item.kind === 'setting' || (item.kind === 'notification' && item.payload?.type === 'admission_round_publication')))
|
||||||
|
: admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2] && (sourceType !== 'reporting' || item.payload?.type === 'admission_reporting'));
|
||||||
|
if (!record) return sendError(response, 404, '系统公示不存在');
|
||||||
|
const body = await readJson(request);
|
||||||
|
if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
|
||||||
|
record.payload = { ...record.payload, publicVisible: body.visible };
|
||||||
|
record.updatedAt = nowIso();
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, body.visible ? '显示系统公示' : '隐藏系统公示', `${sourceType} · ${record.id}`));
|
||||||
|
await cache.invalidate('public');
|
||||||
|
return sendJson(response, 200, { ok: true, publication: systemPublications({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }).find(item => item.id === record.id && item.sourceType === sourceType) });
|
||||||
|
}
|
||||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && noticeMatch) {
|
if (request.method === 'PATCH' && noticeMatch) {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
@@ -1326,6 +1705,7 @@ export function createAdminRoutes(context) {
|
|||||||
}
|
}
|
||||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||||
await database.updateNotice(notice, log);
|
await database.updateNotice(notice, log);
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
||||||
@@ -1340,53 +1720,67 @@ export function createAdminRoutes(context) {
|
|||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||||
if (!requirePermission(user, response, 'results.read')) return true;
|
if (!requirePermission(user, response, 'results.read')) return true;
|
||||||
|
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||||
|
const selectedExam = db.exams.find(item => item.id === requestedExamId);
|
||||||
|
if (!selectedExam) return sendError(response, 404, '请选择有效的考试后再读取成绩');
|
||||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||||
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
const selectedScopedRegistrations = scopedRegistrations.filter(item => item.examId === selectedExam.id);
|
||||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
if (user.adminLevel !== 'super' && !selectedScopedRegistrations.length) return sendError(response, 404, '该考试不在当前管理范围内');
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
const approved = selectedScopedRegistrations.filter(item => item.status === 'approved');
|
||||||
const account = db.users.find(item => item.id === registration?.userId);
|
const selectedRegistrationIds = new Set(selectedScopedRegistrations.map(item => item.id));
|
||||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
const examRegistrations = db.registrations.filter(item => item.examId === selectedExam.id && item.status === 'approved');
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
const examRegistrationIds = new Set(examRegistrations.map(item => item.id));
|
||||||
const pass = subjectPassEvaluation(db, result, subject);
|
const examResults = db.results.filter(result => examRegistrationIds.has(result.registrationId));
|
||||||
const rank = resultRankInfo(db, result);
|
const scoreDb = { ...db, registrations: examRegistrations, results: examResults };
|
||||||
|
const registrationById = new Map(selectedScopedRegistrations.map(item => [item.id, item]));
|
||||||
|
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||||
|
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||||
|
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||||
|
const rawScopedResults = examResults.filter(result => selectedRegistrationIds.has(result.registrationId));
|
||||||
|
const results = rawScopedResults.map(result => {
|
||||||
|
const registration = registrationById.get(result.registrationId);
|
||||||
|
const profile = profileByUserId.get(registration?.userId);
|
||||||
|
const account = accountById.get(registration?.userId);
|
||||||
|
const subject = selectedExam.subjects.find(item => item.id === result.subjectId);
|
||||||
|
const pass = subjectPassEvaluation(scoreDb, result, subject);
|
||||||
return {
|
return {
|
||||||
...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent,
|
...result, grade: result.published ? pass.grade : result.grade, rank: pass.rank, cohortSize: pass.cohortSize, rankPercent: pass.rankPercent,
|
||||||
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
|
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
|
||||||
schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
|
schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '',
|
||||||
examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name,
|
examId: selectedExam.id, examCode: selectedExam.code, examName: selectedExam.name, subjectName: subject?.name,
|
||||||
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
|
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
|
||||||
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
|
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
|
||||||
qualified: pass.qualified
|
qualified: pass.qualified
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
|
const resultById = new Map(results.map(result => [result.id, result]));
|
||||||
const result = results.find(item => item.id === instance.businessId);
|
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && resultById.has(instance.businessId)).map(instance => {
|
||||||
|
const result = resultById.get(instance.businessId);
|
||||||
const workflow = workflowView(db, instance);
|
const workflow = workflowView(db, instance);
|
||||||
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
||||||
});
|
});
|
||||||
const approved = scopedRegistrations.filter(item => item.status === 'approved');
|
|
||||||
const exams = db.exams.map(exam => {
|
const exams = db.exams.map(exam => {
|
||||||
const examRegistrations = approved.filter(item => item.examId === exam.id);
|
if (exam.id !== selectedExam.id) return publicExam(exam);
|
||||||
const examResults = results.filter(item => item.examId === exam.id);
|
const summaries = approved.map(item => examResultSummary(scoreDb, item)).filter(Boolean);
|
||||||
const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean);
|
const enrolledSubjects = approved.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
||||||
const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
const scored = rawScopedResults.length;
|
||||||
const scored = examResults.length;
|
|
||||||
return {
|
return {
|
||||||
...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored,
|
...publicExam(exam), registrationCount: approved.length, enrolledSubjects, scored,
|
||||||
published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
published: rawScopedResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
||||||
complete: summaries.filter(item => item.complete).length,
|
complete: summaries.filter(item => item.complete).length,
|
||||||
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
|
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
|
||||||
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
|
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
|
||||||
appeals: appeals.filter(item => item.result?.examId === exam.id).length
|
appeals: appeals.length
|
||||||
};
|
};
|
||||||
}).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super');
|
});
|
||||||
const registrations = user.adminLevel === 'super' ? approved.map(item => {
|
const registrations = user.adminLevel === 'super' ? approved.map(item => {
|
||||||
const view = examRegistrationView(db, item);
|
const view = examRegistrationView(db, item);
|
||||||
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId);
|
const profile = profileByUserId.get(item.userId);
|
||||||
const account = db.users.find(accountItem => accountItem.id === item.userId);
|
const account = accountById.get(item.userId);
|
||||||
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
|
const specialty = resolveProfileSpecialty(profile || {});
|
||||||
|
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' };
|
||||||
}) : [];
|
}) : [];
|
||||||
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
return sendJson(response, 200, { ok: true, selectedExamId: selectedExam.id, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
@@ -1394,6 +1788,53 @@ export function createAdminRoutes(context) {
|
|||||||
const result = await commitResultImport(db, user, body.rows);
|
const result = await commitResultImport(db, user, body.rows);
|
||||||
return sendJson(response, 200, { ok: true, ...result });
|
return sendJson(response, 200, { ok: true, ...result });
|
||||||
}
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admin/results/bulk') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||||
|
const subject = exam?.subjects.find(item => item.id === cleanText(body.subjectId, 64));
|
||||||
|
if (!exam || !subject) return sendError(response, 400, '请选择有效且未归档的考试科目');
|
||||||
|
const sourceRows = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
|
||||||
|
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved' && item.subjectIds.includes(subject.id));
|
||||||
|
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生成绩无效或重复`);
|
||||||
|
seen.add(registration.id);
|
||||||
|
const account = db.users.find(item => item.id === registration.userId);
|
||||||
|
sourceRows.push({
|
||||||
|
__row: index + 3,
|
||||||
|
candidateNumber: account?.candidateNumber || registration.registrationNumber || '',
|
||||||
|
examCode: exam.code,
|
||||||
|
subjectName: subject.name,
|
||||||
|
score: row.score,
|
||||||
|
published: body.published === true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!sourceRows.length) return sendError(response, 400, '没有需要保存的成绩');
|
||||||
|
const result = await commitResultImport(db, user, sourceRows);
|
||||||
|
return sendJson(response, 200, { ok: true, published: body.published === true, ...result });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admin/feature-scores/bulk') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||||
|
if (!exam) return sendError(response, 400, '请选择有效且未归档的考试');
|
||||||
|
const entries = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
|
||||||
|
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved');
|
||||||
|
const featureScore = Number(row.featureScore);
|
||||||
|
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生记录无效或重复`);
|
||||||
|
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, `第 ${index + 1} 条特征分必须在 0—1000 之间`);
|
||||||
|
seen.add(registration.id);
|
||||||
|
registration.featureScore = Number(featureScore.toFixed(2));
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
entries.push({ registration, log: logAction(db, user, '批量登记特征分', `${profile?.name || registration.userId} · ${exam.name} · ${registration.featureScore}`) });
|
||||||
|
}
|
||||||
|
if (!entries.length) return sendError(response, 400, '没有需要保存的特征分');
|
||||||
|
await database.updateFeatureScores(entries);
|
||||||
|
return sendJson(response, 200, { ok: true, count: entries.length });
|
||||||
|
}
|
||||||
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
|
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
|
||||||
if (request.method === 'PATCH' && featureScoreMatch) {
|
if (request.method === 'PATCH' && featureScoreMatch) {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
|||||||
+251
-13
@@ -1,5 +1,6 @@
|
|||||||
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
import { admissionPlanProgress, admissionRecords, admissionReportingRecord, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||||
|
|
||||||
function normalizeCategories(input, cleanText) {
|
function normalizeCategories(input, cleanText) {
|
||||||
const source = Array.isArray(input) ? input : [];
|
const source = Array.isArray(input) ? input : [];
|
||||||
@@ -16,21 +17,77 @@ function normalizeCategories(input, cleanText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createAdmissionRoutes(context) {
|
export function createAdmissionRoutes(context) {
|
||||||
const { database, readDb, sendJson, sendError, readJson, sendWorkbook, buildWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
|
const { database, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, buildWorkbook, parseWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction, documentVerificationSecret, admissionNoticeCode, safeCodeEqual } = context;
|
||||||
|
|
||||||
|
const reportingStatusByCode = { Y: 'reported', N: 'not_reported', P: 'pending' };
|
||||||
|
const reportingCodeByStatus = { reported: 'Y', not_reported: 'N', pending: 'P' };
|
||||||
|
|
||||||
|
function reportingRows(db, plan, record) {
|
||||||
|
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||||
|
const school = db.schools.find(item => item.id === plan.schoolId) || {};
|
||||||
|
const rowByPlacement = new Map((record?.payload?.rows || []).map(item => [item.placementId, item]));
|
||||||
|
const placementIds = new Set((record?.payload?.rows || []).map(item => item.placementId));
|
||||||
|
const round = Number(record?.payload?.round || 1);
|
||||||
|
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.status === 'final' && (placementIds.has(item.id) || (!record && Number(item.payload?.finalizedRound || 1) === round)));
|
||||||
|
return placements.map(placement => {
|
||||||
|
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||||
|
const row = rowByPlacement.get(placement.id) || {};
|
||||||
|
return {
|
||||||
|
placementId: placement.id,
|
||||||
|
noticeNumber: placement.payload?.noticeNumber || '',
|
||||||
|
candidateNumber: account.candidateNumber || '',
|
||||||
|
name: profile.name || account.displayName || '',
|
||||||
|
idNumberMasked: maskId(profile.idNumber),
|
||||||
|
examCode: exam.code || '',
|
||||||
|
schoolCode: school.code || '',
|
||||||
|
categoryName: placement.payload?.categoryName || '',
|
||||||
|
status: row.status || 'pending',
|
||||||
|
statusCode: reportingCodeByStatus[row.status] || 'P',
|
||||||
|
note: row.note || '',
|
||||||
|
updatedAt: row.updatedAt || null
|
||||||
|
};
|
||||||
|
}).sort((left, right) => left.candidateNumber.localeCompare(right.candidateNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportingBatch(db, plan, record) {
|
||||||
|
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||||
|
return { id: record?.id || '', exam: { id: exam.id, code: exam.code, name: exam.name }, round: Number(record?.payload?.round || 1), status: record?.status || 'not_started', rows: reportingRows(db, plan, record), progress: admissionPlanProgress(db, plan), supplementDecision: record?.payload?.supplementDecision || '', decisionNote: record?.payload?.decisionNote || '', approvalNote: record?.payload?.approvalNote || '', updatedAt: record?.updatedAt || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableReportingRecord(db, examId, schoolId) {
|
||||||
|
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||||
|
const record = admissionReportingRecord(db, examId, schoolId, Number(setting?.payload?.round || 1)) || admissionReportingRecord(db, examId, schoolId);
|
||||||
|
return { setting, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportingScanTarget(db, school, rawCode) {
|
||||||
|
const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
|
||||||
|
if (!match) return { error: [400, '未识别到有效的录取通知书防伪码'] };
|
||||||
|
const code = match[0];
|
||||||
|
const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {})));
|
||||||
|
if (!placement) return { error: [404, '该二维码不属于本校有效录取通知书'] };
|
||||||
|
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, placement.examId, school.id);
|
||||||
|
if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return { error: [409, '该考生不在当前可维护的报到批次'] };
|
||||||
|
return { code, placement, plan, record };
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdmission(request, response, pathname) {
|
async function handleAdmission(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/admission/')) return false;
|
if (!pathname.startsWith('/api/admission/')) return false;
|
||||||
const user = await requireUser(request, response, 'admission_school');
|
const user = await requireUser(request, response, 'admission_school');
|
||||||
if (!user) return true;
|
if (!user) return true;
|
||||||
const db = await readDb();
|
const db = request.authDb || await readDb();
|
||||||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||||
|
|
||||||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||||||
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
|
const plans = approvedPlans(db).filter(item => item.schoolId === school.id).map(item => ({ ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', progress: admissionPlanProgress(db, item) }));
|
||||||
|
const notifications = systemNotificationItems(db).filter(item => item.visible && (!item.schoolId || item.schoolId === school.id)).slice(0, 6).map(item => ({ ...item, id: item.noticeId }));
|
||||||
|
return sendJson(response, 200, { ok: true, school, plans, notifications, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
||||||
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan) }));
|
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||||
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
||||||
@@ -52,17 +109,178 @@ export function createAdmissionRoutes(context) {
|
|||||||
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
||||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
|
||||||
|
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
|
||||||
|
const template = record?.payload?.template || {
|
||||||
|
eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
|
||||||
|
body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
|
||||||
|
footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
|
||||||
|
};
|
||||||
|
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
|
||||||
|
}
|
||||||
|
if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
|
||||||
|
if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
|
||||||
|
const template = {
|
||||||
|
eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
|
||||||
|
title: cleanText(body.title || '录 取 通 知 书', 80),
|
||||||
|
body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
|
||||||
|
primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
|
||||||
|
accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
|
||||||
|
};
|
||||||
|
if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
|
||||||
|
const now = nowIso();
|
||||||
|
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
|
||||||
|
|| { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
|
||||||
|
Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
|
||||||
|
return sendJson(response, 200, { ok: true, template, updatedAt: now });
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admission/reporting') {
|
||||||
|
const plans = approvedPlans(db).filter(item => item.schoolId === school.id);
|
||||||
|
const batches = plans.map(plan => reportingBatch(db, plan, admissionReportingRecord(db, plan.examId, school.id)));
|
||||||
|
return sendJson(response, 200, { ok: true, school, batches });
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admission/reporting/export') {
|
||||||
|
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||||
|
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, examId, school.id);
|
||||||
|
if (!plan || !record) return sendError(response, 404, '当前考试还没有可维护的报到批次');
|
||||||
|
const rows = reportingRows(db, plan, record).map(item => ({
|
||||||
|
noticeNumber: item.noticeNumber, candidateNumber: item.candidateNumber, name: item.name,
|
||||||
|
examCode: item.examCode, schoolCode: item.schoolCode, categoryName: item.categoryName,
|
||||||
|
reportingStatusCode: item.statusCode, reportingNote: item.note
|
||||||
|
}));
|
||||||
|
const buffer = Buffer.from(await buildWorkbook('admission_reporting', rows, { subtitle: `${record.payload?.round || 1} 轮|${school.name}` }));
|
||||||
|
return sendWorkbook(response, buffer, `${record.payload?.round || 1}轮-${school.name}-考生报到状态.xlsx`);
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/import') {
|
||||||
|
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||||
|
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, examId, school.id);
|
||||||
|
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能导入暂存数据');
|
||||||
|
const imported = await parseWorkbook('admission_reporting', await readBodyBuffer(request));
|
||||||
|
const available = reportingRows(db, plan, record);
|
||||||
|
const byNotice = new Map(available.map(item => [item.noticeNumber, item]));
|
||||||
|
const byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
|
||||||
|
const seen = new Set();
|
||||||
|
const updates = [];
|
||||||
|
const changes = [];
|
||||||
|
let unchangedCount = 0;
|
||||||
|
const importedAt = nowIso();
|
||||||
|
for (const item of imported) {
|
||||||
|
const noticeNumber = cleanText(item.noticeNumber, 100);
|
||||||
|
const candidateNumber = cleanText(item.candidateNumber, 100);
|
||||||
|
const target = byNotice.get(noticeNumber);
|
||||||
|
if (!target || byCandidate.get(candidateNumber)?.placementId !== target.placementId) return sendError(response, 400, `Excel 第 ${item.__row} 行的通知书编号与报名号不属于本校当前报到批次`);
|
||||||
|
if (seen.has(target.placementId)) return sendError(response, 400, `Excel 第 ${item.__row} 行重复填写同一考生`);
|
||||||
|
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
|
||||||
|
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
|
||||||
|
seen.add(target.placementId);
|
||||||
|
const status = reportingStatusByCode[code];
|
||||||
|
const note = cleanText(item.reportingNote, 300);
|
||||||
|
if (target.status === status && target.note === note) { unchangedCount += 1; continue; }
|
||||||
|
updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' });
|
||||||
|
changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note });
|
||||||
|
}
|
||||||
|
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||||
|
updates.forEach(item => merged.set(item.placementId, item));
|
||||||
|
if (updates.length) {
|
||||||
|
record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · 实际更新 ${updates.length} 人`));
|
||||||
|
}
|
||||||
|
const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db;
|
||||||
|
return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, plan, record) });
|
||||||
|
}
|
||||||
|
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const examId = cleanText(body.examId, 64);
|
||||||
|
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, examId, school.id);
|
||||||
|
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能修改暂存状态');
|
||||||
|
const available = new Set(reportingRows(db, plan, record).map(item => item.placementId));
|
||||||
|
const updates = (Array.isArray(body.rows) ? body.rows : []).map(item => ({ placementId: cleanText(item.placementId, 64), status: cleanText(item.status, 30), note: cleanText(item.note, 300), updatedAt: nowIso(), source: 'manual' }));
|
||||||
|
if (!updates.length || updates.some(item => !available.has(item.placementId) || !['pending', 'reported', 'not_reported'].includes(item.status))) return sendError(response, 400, '报到暂存数据无效');
|
||||||
|
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||||
|
updates.forEach(item => merged.set(item.placementId, item));
|
||||||
|
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length} 人`));
|
||||||
|
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan-preview') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const target = reportingScanTarget(db, school, body.code);
|
||||||
|
if (target.error) return sendError(response, ...target.error);
|
||||||
|
const { code, placement, plan, record } = target;
|
||||||
|
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||||
|
const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id);
|
||||||
|
return sendJson(response, 200, { ok: true, code, examId: placement.examId, row });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const target = reportingScanTarget(db, school, body.code);
|
||||||
|
if (target.error) return sendError(response, ...target.error);
|
||||||
|
const { placement, plan, record } = target;
|
||||||
|
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||||
|
const status = cleanText(body.status, 30);
|
||||||
|
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
|
||||||
|
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||||
|
const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认';
|
||||||
|
merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, updatedAt: nowIso(), source: 'qr_scan' });
|
||||||
|
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, '扫码确认并暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id} · ${reportingCodeByStatus[status]}`));
|
||||||
|
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||||
|
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/submit') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const examId = cleanText(body.examId, 64);
|
||||||
|
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, examId, school.id);
|
||||||
|
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能提交');
|
||||||
|
const rows = reportingRows(db, plan, record);
|
||||||
|
if (rows.some(item => item.status === 'pending')) return sendError(response, 409, `仍有 ${rows.filter(item => item.status === 'pending').length} 名考生待确认,请全部标记后提交`);
|
||||||
|
record.status = 'submitted'; record.updatedAt = nowIso(); record.payload = { ...record.payload, submittedAt: record.updatedAt, submittedBy: user.displayName };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, '提交考生报到情况', `${school.name} · ${rows.length} 人`));
|
||||||
|
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||||
|
return sendJson(response, 200, { ok: true, batch: reportingBatch(nextDb, plan, record) });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/decision') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const examId = cleanText(body.examId, 64);
|
||||||
|
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||||
|
const { record } = editableReportingRecord(db, examId, school.id);
|
||||||
|
if (!plan || !record || record.status !== 'submitted') return sendError(response, 409, '请先提交本轮考生报到情况');
|
||||||
|
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||||
|
const progress = admissionPlanProgress(nextDb, plan);
|
||||||
|
const supplement = body.supplement === true && progress.reportingGap > 0;
|
||||||
|
const decisionNote = cleanText(body.decisionNote, 500);
|
||||||
|
if (supplement && decisionNote.length < 4) return sendError(response, 400, '申请补录时请填写至少 4 个字的补录说明');
|
||||||
|
record.status = 'pending_approval'; record.updatedAt = nowIso(); record.payload = { ...record.payload, supplementDecision: supplement ? 'supplement' : 'no_supplement', decisionNote: decisionNote || (progress.reportingGap ? '经学校研究决定,本轮不进行补录。' : '本校招生计划已完成。'), decisionSubmittedAt: record.updatedAt, decisionSubmittedBy: user.displayName, statistics: progress };
|
||||||
|
await database.saveAdmissionRecord(record, logAction(db, user, supplement ? '提交补录申请' : '提交不补录决定', `${school.name} · 缺额 ${progress.reportingGap} 人`));
|
||||||
|
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||||
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
||||||
|
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||||
|
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||||
|
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||||
|
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
|
||||||
|
const publishedResultsByRegistration = new Map();
|
||||||
|
for (const result of db.results) {
|
||||||
|
if (!result.published) continue;
|
||||||
|
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
|
||||||
|
rows.push(result);
|
||||||
|
publishedResultsByRegistration.set(result.registrationId, rows);
|
||||||
|
}
|
||||||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||||||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
const account = accountById.get(item.userId) || {};
|
||||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
const profile = profileByUserId.get(item.userId) || {};
|
||||||
const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId);
|
const exam = examById.get(item.examId);
|
||||||
const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => {
|
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
|
||||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||||
return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
|
|
||||||
});
|
|
||||||
const qualification = resolveProfileSpecialty(profile);
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||||
});
|
});
|
||||||
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||||||
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||||||
@@ -93,6 +311,26 @@ export function createAdmissionRoutes(context) {
|
|||||||
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||||||
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||||||
}
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
|
||||||
|
const decision = cleanText(body.decision, 30);
|
||||||
|
const note = cleanText(body.note, 500);
|
||||||
|
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
|
||||||
|
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
|
||||||
|
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
|
||||||
|
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
|
||||||
|
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
|
||||||
|
const now = nowIso();
|
||||||
|
for (const placement of placements) {
|
||||||
|
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
|
||||||
|
placement.payload.schoolDecisionNote = note;
|
||||||
|
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
|
||||||
|
placement.updatedAt = now;
|
||||||
|
}
|
||||||
|
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
|
||||||
|
return sendJson(response, 200, { ok: true, count: placements.length, decision });
|
||||||
|
}
|
||||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && placementMatch) {
|
if (request.method === 'PATCH' && placementMatch) {
|
||||||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { noticeForClient } from '../security/notice-content.mjs';
|
import { noticeForClient } from '../security/notice-content.mjs';
|
||||||
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota, supplementarySchoolIds } from '../services/volunteer-admission.mjs';
|
||||||
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||||
|
|
||||||
export function createCandidateRoutes(context) {
|
export function createCandidateRoutes(context) {
|
||||||
const {
|
const {
|
||||||
@@ -40,6 +42,9 @@ export function createCandidateRoutes(context) {
|
|||||||
examResultSummary,
|
examResultSummary,
|
||||||
subjectPassText,
|
subjectPassText,
|
||||||
resultRankInfo,
|
resultRankInfo,
|
||||||
|
documentVerificationSecret,
|
||||||
|
scoreReportCode,
|
||||||
|
admissionNoticeCode,
|
||||||
subjectPassEvaluation,
|
subjectPassEvaluation,
|
||||||
logAction,
|
logAction,
|
||||||
excelResourceNames,
|
excelResourceNames,
|
||||||
@@ -58,6 +63,13 @@ export function createCandidateRoutes(context) {
|
|||||||
resolveRegion
|
resolveRegion
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
|
const verificationUrl = (request, code) => {
|
||||||
|
const protocol = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim() || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||||
|
const host = request.headers.host || `${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 4173}`;
|
||||||
|
return `${protocol}://${host}/#verify/${encodeURIComponent(code)}`;
|
||||||
|
};
|
||||||
|
const verificationQr = (request, code) => QRCode.toDataURL(verificationUrl(request, code), { errorCorrectionLevel: 'M', margin: 1, width: 320 });
|
||||||
|
|
||||||
async function handleCandidate(request, response, pathname) {
|
async function handleCandidate(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/candidate/')) return false;
|
if (!pathname.startsWith('/api/candidate/')) return false;
|
||||||
const user = await requireUser(request, response, 'candidate');
|
const user = await requireUser(request, response, 'candidate');
|
||||||
@@ -71,11 +83,21 @@ export function createCandidateRoutes(context) {
|
|||||||
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
|
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
|
||||||
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
||||||
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
||||||
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5).map(noticeForClient);
|
const notices = [
|
||||||
|
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
|
||||||
|
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||||
|
].sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
|
||||||
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
|
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/candidate/notices') {
|
||||||
|
const notices = [
|
||||||
|
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
|
||||||
|
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||||
|
].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||||
|
return sendJson(response, 200, { ok: true, notices });
|
||||||
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
||||||
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
@@ -152,21 +174,54 @@ export function createCandidateRoutes(context) {
|
|||||||
appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null
|
appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
const summaries = await Promise.all(registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(async summary => {
|
||||||
return { ok: true, results, summaries };
|
const registration = registrations.find(item => item.examId === summary.examId);
|
||||||
|
const exam = db.exams.find(item => item.id === summary.examId);
|
||||||
|
const reportResults = db.results.filter(item => item.registrationId === registration?.id && item.published);
|
||||||
|
const verificationCode = registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '';
|
||||||
|
return { ...summary, verificationCode, verificationQr: verificationCode ? await verificationQr(request, verificationCode) : '' };
|
||||||
|
}));
|
||||||
|
return { ok: true, results, summaries, candidate: { name: profile.name || user.displayName, candidateNumber: user.candidateNumber || '' } };
|
||||||
}, { ttlSeconds: resultsCacheTtlSeconds });
|
}, { ttlSeconds: resultsCacheTtlSeconds });
|
||||||
return sendJson(response, 200, payload);
|
return sendJson(response, 200, payload);
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
|
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
|
||||||
const settings = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
|
const settings = (await Promise.all(admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(async setting => {
|
||||||
const exam = db.exams.find(item => item.id === setting.examId);
|
const exam = db.exams.find(item => item.id === setting.examId);
|
||||||
const round = Number(setting.payload?.round || 1);
|
const round = Number(setting.payload?.round || 1);
|
||||||
const preference = activePreference(db, setting.examId, user.id, round);
|
const preference = activePreference(db, setting.examId, user.id, round);
|
||||||
|
const preferenceView = preference ? {
|
||||||
|
...preference,
|
||||||
|
payload: {
|
||||||
|
...preference.payload,
|
||||||
|
choices: (preference.payload?.choices || []).map(choice => {
|
||||||
|
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||||
|
const categories = admissionRecords(db, 'plan', setting.examId)
|
||||||
|
.filter(item => item.schoolId === choice.schoolId)
|
||||||
|
.flatMap(item => item.payload?.categories || []);
|
||||||
|
const category = categories.find(item => item.code === choice.categoryCode)
|
||||||
|
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||||
|
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||||
|
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||||
|
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} : null;
|
||||||
const qualification = indicatorQualification(db, setting.examId, user.id);
|
const qualification = indicatorQualification(db, setting.examId, user.id);
|
||||||
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
||||||
const plans = approvedPlans(db, setting.examId).map(plan => {
|
const blockingPlacement = setting.status === 'supplementary'
|
||||||
|
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||||
|
: null;
|
||||||
|
const supplementEligible = !blockingPlacement;
|
||||||
|
const supplementIneligibilityReason = blockingPlacement?.status === 'forfeited'
|
||||||
|
? '因本轮未按规定完成报到,不能再次参加补录。'
|
||||||
|
: blockingPlacement
|
||||||
|
? '你已被录取,本轮补录无需且不能再次填报。'
|
||||||
|
: '';
|
||||||
|
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||||
|
const plans = (supplementEligible ? approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)) : []).map(plan => {
|
||||||
const school = db.schools.find(item => item.id === plan.schoolId);
|
const school = db.schools.find(item => item.id === plan.schoolId);
|
||||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.status !== 'withdrawn');
|
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
|
||||||
return {
|
return {
|
||||||
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
|
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
|
||||||
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
|
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
|
||||||
@@ -184,9 +239,19 @@ export function createCandidateRoutes(context) {
|
|||||||
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
|
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
|
||||||
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||||
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
|
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
|
||||||
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
|
const school = placement ? db.schools.find(item => item.id === placement.schoolId) : null;
|
||||||
}).filter(item => item.exam);
|
const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null;
|
||||||
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
|
const noticeTemplate = templateRecord?.payload?.template || null;
|
||||||
|
const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : '';
|
||||||
|
const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : '';
|
||||||
|
return { ...setting, exam: exam ? publicExam(exam) : null, preference: preferenceView, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, supplementIneligibilityReason, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
|
||||||
|
}))).filter(item => item.exam);
|
||||||
|
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id).map(item => {
|
||||||
|
const placement = admissionRecords(db, 'placement', item.examId).find(entry => entry.id === item.payload?.placementId);
|
||||||
|
const school = db.schools.find(entry => entry.id === (placement?.schoolId || item.schoolId));
|
||||||
|
const exam = db.exams.find(entry => entry.id === item.examId);
|
||||||
|
return { ...item, examName: exam?.name || '', schoolName: school?.name || '', schoolCode: school?.code || '', categoryName: placement?.payload?.categoryName || '', noticeNumber: placement?.payload?.noticeNumber || '', placementStatus: placement?.status || '' };
|
||||||
|
}).sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
||||||
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
||||||
}
|
}
|
||||||
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
|
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
|
||||||
@@ -194,6 +259,11 @@ export function createCandidateRoutes(context) {
|
|||||||
const setting = admissionSetting(db, preferenceMatch[1]);
|
const setting = admissionSetting(db, preferenceMatch[1]);
|
||||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
|
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
|
||||||
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
|
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
|
||||||
|
if (setting.status === 'supplementary') {
|
||||||
|
const blockingPlacement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status));
|
||||||
|
if (blockingPlacement?.status === 'forfeited') return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录');
|
||||||
|
if (blockingPlacement) return sendError(response, 403, '你已被录取,本轮补录不能再次填报');
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
|
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
|
||||||
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
|
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
|
||||||
@@ -212,11 +282,12 @@ export function createCandidateRoutes(context) {
|
|||||||
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
|
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
|
||||||
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
|
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
|
||||||
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
|
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
|
||||||
const plans = approvedPlans(db, setting.examId);
|
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||||
|
const plans = approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
|
||||||
const indicator = indicatorQualification(db, setting.examId, user.id);
|
const indicator = indicatorQualification(db, setting.examId, user.id);
|
||||||
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
|
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
|
||||||
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
|
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
|
||||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn');
|
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status));
|
||||||
if (choice.preferenceType === 'indicator') {
|
if (choice.preferenceType === 'indicator') {
|
||||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
||||||
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
||||||
@@ -228,7 +299,12 @@ export function createCandidateRoutes(context) {
|
|||||||
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
|
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
|
||||||
const nowValue = nowIso();
|
const nowValue = nowIso();
|
||||||
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
||||||
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
|
const storedChoices = choices.map(choice => {
|
||||||
|
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||||
|
const category = plans.find(plan => plan.schoolId === choice.schoolId)?.payload?.categories?.find(item => item.code === choice.categoryCode);
|
||||||
|
return { ...choice, schoolCode: school?.code || '', schoolName: school?.name || '', categoryName: category?.name || '' };
|
||||||
|
});
|
||||||
|
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices: storedChoices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
|
||||||
await database.saveAdmissionRecord(preference);
|
await database.saveAdmissionRecord(preference);
|
||||||
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
|
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { noticeForClient } from '../security/notice-content.mjs';
|
import { noticeForClient } from '../security/notice-content.mjs';
|
||||||
import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
import { admissionRecords, admissionRoundPublications, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||||
|
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||||
|
|
||||||
export function createPublicRoutes(context) {
|
export function createPublicRoutes(context) {
|
||||||
const {
|
const {
|
||||||
@@ -50,13 +52,42 @@ export function createPublicRoutes(context) {
|
|||||||
hasExcelResource,
|
hasExcelResource,
|
||||||
parseWorkbook,
|
parseWorkbook,
|
||||||
adminLevelNames
|
adminLevelNames
|
||||||
|
, documentVerificationSecret, scoreReportCode, admissionNoticeCode, safeCodeEqual
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
async function handlePublic(pathname, response) {
|
async function handlePublic(pathname, response) {
|
||||||
|
const verificationMatch = pathname.match(/^\/api\/public\/verifications\/([^/]+)$/);
|
||||||
|
if (verificationMatch) {
|
||||||
|
const db = await readDb();
|
||||||
|
const code = decodeURIComponent(verificationMatch[1]).toUpperCase();
|
||||||
|
const hideName = value => value ? `${value.slice(0, 1)}${'*'.repeat(Math.max(1, value.length - 1))}` : '';
|
||||||
|
if (code.startsWith('SR-')) {
|
||||||
|
for (const registration of db.registrations) {
|
||||||
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
|
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
|
||||||
|
if (!exam || !results.length || !safeCodeEqual(code, scoreReportCode(documentVerificationSecret, registration, exam, results))) continue;
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
|
||||||
|
const user = db.users.find(item => item.id === registration.userId) || {};
|
||||||
|
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'score-report', typeName: '考生成绩单', candidateName: hideName(profile.name || user.displayName), candidateNumber: String(user.candidateNumber || registration.registrationNumber || '').replace(/^(.{3}).+(.{3})$/, '$1****$2'), examName: exam.name, subjectCount: results.length, totalScore: Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)), issuedAt: [...results].sort((a, b) => new Date(b.publishedAt || b.updatedAt) - new Date(a.publishedAt || a.updatedAt))[0]?.publishedAt } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code.startsWith('AN-')) {
|
||||||
|
for (const placement of admissionRecords(db, 'placement').filter(item => item.status === 'final')) {
|
||||||
|
const exam = db.exams.find(item => item.id === placement.examId);
|
||||||
|
if (!exam || !safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, placement, exam))) continue;
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||||
|
const school = db.schools.find(item => item.id === placement.schoolId) || {};
|
||||||
|
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'admission-notice', typeName: '录取通知书', noticeNumber: placement.payload?.noticeNumber || '', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sendError(response, 404, '未查询到有效文书,请核对防伪码');
|
||||||
|
}
|
||||||
if (pathname === '/api/public/home') {
|
if (pathname === '/api/public/home') {
|
||||||
const payload = await cache.remember('public', 'home', async () => {
|
const payload = await cache.remember('public', 'home', async () => {
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
const manualNotices = db.notices.filter(item => item.status === 'published').map(noticeForClient);
|
||||||
|
const automaticNotices = systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }));
|
||||||
|
const publishedNotices = [...manualNotices, ...automaticNotices].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||||
});
|
});
|
||||||
@@ -65,13 +96,36 @@ export function createPublicRoutes(context) {
|
|||||||
if (pathname === '/api/public/announcements') {
|
if (pathname === '/api/public/announcements') {
|
||||||
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
|
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
examId: item.examId,
|
||||||
|
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
|
||||||
|
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||||
|
publishedAt: item.payload?.reviewedAt || item.updatedAt,
|
||||||
|
note: item.payload?.note || '',
|
||||||
|
rows: (item.payload?.categories || []).map(category => ({
|
||||||
|
code: category.code,
|
||||||
|
name: category.name,
|
||||||
|
quota: Number(category.quota || 0),
|
||||||
|
specialtyCategory: category.specialtyCategory || '',
|
||||||
|
specialtyType: category.specialtyType || '',
|
||||||
|
specialtyLabel: specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类',
|
||||||
|
indicatorQuota: (category.indicatorAllocations || []).reduce((sum, allocation) => sum + Number(allocation.quota || 0), 0),
|
||||||
|
indicatorAllocations: (category.indicatorAllocations || []).map(allocation => ({
|
||||||
|
sourceSchoolName: db.schools.find(school => school.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId,
|
||||||
|
quota: Number(allocation.quota || 0)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||||
|
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
|
||||||
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
|
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
|
||||||
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
|
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
|
||||||
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||||
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', round: item.round, title: `${db.exams.find(exam => exam.id === item.examId)?.name || ''}第 ${item.round} 轮录取名单公示`, publishedAt: item.publishedAt, rows: item.rows }));
|
||||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
const admissions = [...roundAdmissions, ...admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', title: `${db.exams.find(item => item.id === setting.examId)?.name || ''}最终录取名单`, publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }))].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||||
return { ok: true, qualifications, admissions, cutoffs };
|
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||||
|
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' }));
|
||||||
|
return { ok: true, plans, qualifications, admissions, cutoffs, reports };
|
||||||
});
|
});
|
||||||
return sendJson(response, 200, payload);
|
return sendJson(response, 200, payload);
|
||||||
}
|
}
|
||||||
@@ -80,7 +134,9 @@ export function createPublicRoutes(context) {
|
|||||||
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
|
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||||
return found ? noticeForClient(found) : null;
|
if (found) return noticeForClient(found);
|
||||||
|
const systemNotice = systemNotificationItems(db).find(item => item.noticeId === noticeMatch[1] && item.visible);
|
||||||
|
return systemNotice ? { ...systemNotice, id: systemNotice.noticeId } : null;
|
||||||
});
|
});
|
||||||
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
function signature(secret, type, parts) {
|
||||||
|
return createHmac('sha256', secret).update([type, ...parts].join('\u001f')).digest('hex').slice(0, 24).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentVerificationSecret(env = process.env) {
|
||||||
|
const configured = String(env.DOCUMENT_VERIFICATION_SECRET || '');
|
||||||
|
if (env.NODE_ENV === 'production' && configured.length < 32) {
|
||||||
|
throw new Error('生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET');
|
||||||
|
}
|
||||||
|
return configured || String(env.SESSION_SECRET || '') || 'development-document-verification-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreReportCode(secret, registration, exam, results = []) {
|
||||||
|
const scores = [...results].sort((a, b) => String(a.subjectId).localeCompare(String(b.subjectId))).map(item => `${item.subjectId}:${Number(item.score)}:${item.publishedAt || item.updatedAt || ''}`);
|
||||||
|
return `SR-${signature(secret, 'score-report', [registration.id, registration.userId, exam.id, ...scores])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admissionNoticeCode(secret, placement, exam) {
|
||||||
|
return `AN-${signature(secret, 'admission-notice', [placement.id, placement.userId, placement.schoolId, exam.id, placement.payload?.categoryCode || '', placement.payload?.noticeNumber || '', placement.updatedAt || ''])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeCodeEqual(left, right) {
|
||||||
|
const a = Buffer.from(String(left || '').toUpperCase());
|
||||||
|
const b = Buffer.from(String(right || '').toUpperCase());
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
|
request.authDb = db;
|
||||||
const user = db.users.find(item => item.id === session.userId) || null;
|
const user = db.users.find(item => item.id === session.userId) || null;
|
||||||
return user?.active === false || user?.archivedAt ? null : normalizeUser(user);
|
return user?.active === false || user?.archivedAt ? null : normalizeUser(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { admissionRecords, admissionRoundPublications, admissionSetting } from './volunteer-admission.mjs';
|
||||||
|
|
||||||
|
const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||||
|
|
||||||
|
export function systemNotificationItems(db) {
|
||||||
|
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
|
||||||
|
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
|
||||||
|
const item = (record, sourceType, category, title, summary, publishAt, content) => ({
|
||||||
|
id: record.id,
|
||||||
|
noticeId: `system-${sourceType}-${record.id}`,
|
||||||
|
sourceType,
|
||||||
|
schoolId: record.schoolId || null,
|
||||||
|
examId: record.examId,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
author: '系统自动发布',
|
||||||
|
publishAt,
|
||||||
|
publishedAt: publishAt,
|
||||||
|
pinned: false,
|
||||||
|
visible: record.payload?.publicVisible !== false,
|
||||||
|
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
|
||||||
|
});
|
||||||
|
const plans = admissionRecords(db, 'plan').filter(record => record.status === 'approved').map(record => item(
|
||||||
|
record, 'plan', '招生计划', `${examName(record.examId)} · ${schoolName(record.schoolId)}招生计划公示`,
|
||||||
|
'招生计划审核通过,类别人数与指标分配已经公开。', record.payload?.reviewedAt || record.updatedAt,
|
||||||
|
`<p>${h(schoolName(record.schoolId))}招生计划已经审核通过。</p><ul>${(record.payload?.categories || []).map(category => `<li>${h(category.name)}:${Number(category.quota || 0)} 人</li>`).join('')}</ul>`
|
||||||
|
));
|
||||||
|
const qualifications = admissionRecords(db, 'qualification_publication').filter(record => record.status === 'published').map(record => item(
|
||||||
|
record, 'qualification', '指标资格', `${examName(record.examId)} · ${schoolName(record.schoolId)}指标分配资格公示`,
|
||||||
|
'生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt,
|
||||||
|
`<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>`
|
||||||
|
));
|
||||||
|
const roundAdmissions = admissionRoundPublications(db).filter(record => admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
|
||||||
|
record, 'admission', '录取名单', `${examName(record.examId)}第 ${record.round} 轮录取名单公示`,
|
||||||
|
`第 ${record.round} 轮录取通知书已签发,共 ${record.rows.length} 名考生进入本轮录取公示。`, record.publishedAt,
|
||||||
|
`<p>${h(examName(record.examId))}第 ${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。</p>`
|
||||||
|
));
|
||||||
|
const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item(
|
||||||
|
record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`,
|
||||||
|
'录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt,
|
||||||
|
`<p>${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。</p>`
|
||||||
|
));
|
||||||
|
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(record => record.status === 'published' && admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
|
||||||
|
record, 'cutoff', '录取分数线', `${examName(record.examId)}录取分数线`,
|
||||||
|
'各招生学校和类别录取分数线已经由系统汇总发布。', record.payload?.publishedAt || record.updatedAt,
|
||||||
|
`<p>系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。</p>`
|
||||||
|
));
|
||||||
|
const reports = admissionRecords(db, 'notification').filter(record => record.userId == null && record.status === 'approved' && record.payload?.type === 'admission_reporting').map(record => {
|
||||||
|
const stats = record.payload?.statistics || {};
|
||||||
|
const supplement = record.payload?.supplementDecision === 'supplement';
|
||||||
|
const title = supplement
|
||||||
|
? `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况及补录说明`
|
||||||
|
: `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况公示`;
|
||||||
|
const summary = `计划 ${Number(stats.totalQuota || 0)} 人,已报到 ${Number(stats.reportedCount || 0)} 人,完成率 ${Number(stats.reportingRate || 0)}%。`;
|
||||||
|
const decision = supplement ? '学校申请补录并已获批准。' : (record.payload?.decisionNote || '本轮不进行补录。');
|
||||||
|
return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt,
|
||||||
|
`<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`);
|
||||||
|
});
|
||||||
|
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
|
||||||
|
.sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt));
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'supplementary', 'completed']);
|
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'reporting', 'supplementary', 'completed']);
|
||||||
|
|
||||||
export function admissionRecords(db, kind, examId = null) {
|
export function admissionRecords(db, kind, examId = null) {
|
||||||
return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
|
return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
|
||||||
@@ -16,6 +16,15 @@ export function candidateTotalScore(db, examId, userId) {
|
|||||||
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
|
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function candidateAdmissionScore(db, examId, userId, category = {}) {
|
||||||
|
const culturalScore = candidateTotalScore(db, examId, userId);
|
||||||
|
if (culturalScore == null) return null;
|
||||||
|
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
|
||||||
|
const featureScore = Number(registration?.featureScore || 0);
|
||||||
|
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
|
||||||
|
return Number((culturalScore + (usesFeatureScore ? featureScore : 0)).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
export function activePreference(db, examId, userId, round) {
|
export function activePreference(db, examId, userId, round) {
|
||||||
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
|
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
|
||||||
}
|
}
|
||||||
@@ -51,13 +60,115 @@ export function approvedPlans(db, examId) {
|
|||||||
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
|
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supplementarySchoolIds(db, setting) {
|
||||||
|
if (setting?.status !== 'supplementary') return null;
|
||||||
|
const sourceRound = Math.max(1, Number(setting.payload?.round || 1) - 1);
|
||||||
|
const schoolIds = admissionRecords(db, 'notification', setting.examId)
|
||||||
|
.filter(item => item.userId == null
|
||||||
|
&& item.status === 'approved'
|
||||||
|
&& item.payload?.type === 'admission_reporting'
|
||||||
|
&& Number(item.payload?.round || 1) === sourceRound
|
||||||
|
&& item.payload?.supplementDecision === 'supplement')
|
||||||
|
.map(item => item.schoolId)
|
||||||
|
.filter(Boolean);
|
||||||
|
// Older data could enter the supplementary phase without reporting decisions.
|
||||||
|
// Preserve that legacy behavior, while new rounds are restricted to approved schools.
|
||||||
|
return schoolIds.length ? new Set(schoolIds) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function planSummary(plan) {
|
export function planSummary(plan) {
|
||||||
const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
|
const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
|
||||||
return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
|
return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicAdmissionRows(db, examId) {
|
export function admissionReportingRecords(db, examId, schoolId) {
|
||||||
return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => {
|
return admissionRecords(db, 'notification', examId)
|
||||||
|
.filter(item => item.schoolId === schoolId && item.userId == null && item.payload?.type === 'admission_reporting')
|
||||||
|
.sort((left, right) => Number(right.payload?.round || 1) - Number(left.payload?.round || 1) || new Date(right.updatedAt) - new Date(left.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admissionReportingRecord(db, examId, schoolId, round = null) {
|
||||||
|
return admissionReportingRecords(db, examId, schoolId).find(item => round == null || Number(item.payload?.round || 1) === Number(round)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admissionPlanProgress(db, plan) {
|
||||||
|
const totalQuota = (plan.payload?.categories || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
|
||||||
|
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
|
||||||
|
const finalPlacements = placements.filter(item => item.status === 'final');
|
||||||
|
const reportingRecords = admissionReportingRecords(db, plan.examId, plan.schoolId);
|
||||||
|
const reporting = reportingRecords[0] || null;
|
||||||
|
const reportingRows = new Map();
|
||||||
|
for (const record of [...reportingRecords].reverse()) for (const row of record.payload?.rows || []) reportingRows.set(row.placementId, row);
|
||||||
|
const reportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'reported').length;
|
||||||
|
const notReportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'not_reported').length;
|
||||||
|
const pendingReportingCount = Math.max(0, finalPlacements.length - reportedCount - notReportedCount);
|
||||||
|
const percent = value => totalQuota ? Number((value / totalQuota * 100).toFixed(1)) : 0;
|
||||||
|
return {
|
||||||
|
examId: plan.examId,
|
||||||
|
schoolId: plan.schoolId,
|
||||||
|
totalQuota,
|
||||||
|
placedCount: placements.length,
|
||||||
|
finalCount: finalPlacements.length,
|
||||||
|
reportedCount,
|
||||||
|
notReportedCount,
|
||||||
|
pendingReportingCount,
|
||||||
|
admissionRate: percent(finalPlacements.length),
|
||||||
|
reportingRate: percent(reportedCount),
|
||||||
|
remainingQuota: Math.max(0, totalQuota - finalPlacements.length),
|
||||||
|
reportingGap: Math.max(0, totalQuota - reportedCount),
|
||||||
|
reportingStatus: reporting?.status || 'not_started',
|
||||||
|
supplementDecision: reporting?.payload?.supplementDecision || '',
|
||||||
|
reportingUpdatedAt: reporting?.updatedAt || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentCodePart(value, fallback) {
|
||||||
|
const normalized = String(value || '').trim().toUpperCase().replace(/[^A-Z0-9-]+/g, '');
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignAdmissionNoticeNumbers(db, placements) {
|
||||||
|
const counters = new Map();
|
||||||
|
for (const item of admissionRecords(db, 'placement')) {
|
||||||
|
const serial = Number(item.payload?.noticeSerial || String(item.payload?.noticeNumber || '').match(/(\d{6})$/)?.[1] || 0);
|
||||||
|
if (!serial) continue;
|
||||||
|
const key = `${item.schoolId}\u0000${item.examId}`;
|
||||||
|
counters.set(key, Math.max(counters.get(key) || 0, serial));
|
||||||
|
}
|
||||||
|
const accountNumber = userId => db.users.find(item => item.id === userId)?.candidateNumber || userId;
|
||||||
|
const output = [];
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const placement of placements) {
|
||||||
|
const key = `${placement.schoolId}\u0000${placement.examId}`;
|
||||||
|
const rows = grouped.get(key) || [];
|
||||||
|
rows.push(placement);
|
||||||
|
grouped.set(key, rows);
|
||||||
|
}
|
||||||
|
for (const [key, rows] of grouped) {
|
||||||
|
let serial = counters.get(key) || 0;
|
||||||
|
rows.sort((left, right) => String(accountNumber(left.userId)).localeCompare(String(accountNumber(right.userId))));
|
||||||
|
for (const placement of rows) {
|
||||||
|
if (placement.payload?.noticeNumber) {
|
||||||
|
output.push(placement);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serial += 1;
|
||||||
|
const school = db.schools.find(item => item.id === placement.schoolId) || {};
|
||||||
|
const exam = db.exams.find(item => item.id === placement.examId) || {};
|
||||||
|
const noticeSerial = serial;
|
||||||
|
const noticeNumber = `${documentCodePart(school.code, 'SCHOOL')}-${documentCodePart(exam.code, 'EXAM')}-${String(noticeSerial).padStart(6, '0')}`;
|
||||||
|
output.push({ ...placement, payload: { ...placement.payload, noticeSerial, noticeNumber } });
|
||||||
|
}
|
||||||
|
counters.set(key, serial);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicAdmissionRows(db, examId, options = {}) {
|
||||||
|
const round = Math.max(0, Number(options.round || 0));
|
||||||
|
return admissionRecords(db, 'placement', examId).filter(item => round
|
||||||
|
? Number(item.payload?.finalizedRound || 1) === round && ['final', 'forfeited'].includes(item.status)
|
||||||
|
: item.status === 'final').map(item => {
|
||||||
const user = db.users.find(entry => entry.id === item.userId) || {};
|
const user = db.users.find(entry => entry.id === item.userId) || {};
|
||||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||||
const school = db.schools.find(entry => entry.id === item.schoolId) || {};
|
const school = db.schools.find(entry => entry.id === item.schoolId) || {};
|
||||||
@@ -73,6 +184,37 @@ export function publicAdmissionRows(db, examId) {
|
|||||||
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
|
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function admissionRoundPublications(db) {
|
||||||
|
const stored = admissionRecords(db, 'notification')
|
||||||
|
.filter(item => item.userId == null && item.status === 'published' && item.payload?.type === 'admission_round_publication')
|
||||||
|
.map(item => ({ ...item, round: Number(item.payload?.round || 1), publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [], virtual: false }));
|
||||||
|
const keys = new Set(stored.map(item => `${item.examId}:${item.round}`));
|
||||||
|
const fallback = admissionRecords(db, 'setting').filter(item => ['reporting', 'supplementary', 'completed'].includes(item.status)).flatMap(setting => {
|
||||||
|
const rounds = admissionRecords(db, 'placement', setting.examId)
|
||||||
|
.filter(item => ['final', 'forfeited'].includes(item.status))
|
||||||
|
.map(item => Number(item.payload?.finalizedRound || 1));
|
||||||
|
const round = rounds.length ? Math.max(...rounds) : 0;
|
||||||
|
if (!round || keys.has(`${setting.examId}:${round}`)) return [];
|
||||||
|
return [{
|
||||||
|
id: `${setting.id}-round-${round}`,
|
||||||
|
sourceRecordId: setting.id,
|
||||||
|
kind: 'notification',
|
||||||
|
examId: setting.examId,
|
||||||
|
schoolId: null,
|
||||||
|
userId: null,
|
||||||
|
status: 'published',
|
||||||
|
createdAt: setting.updatedAt,
|
||||||
|
updatedAt: setting.updatedAt,
|
||||||
|
round,
|
||||||
|
publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt,
|
||||||
|
rows: publicAdmissionRows(db, setting.examId, { round }),
|
||||||
|
virtual: true,
|
||||||
|
payload: { type: 'admission_round_publication', round, publicVisible: setting.payload?.publicVisible, publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt }
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
return [...stored, ...fallback].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
export function admissionCutoffRows(db, examId) {
|
export function admissionCutoffRows(db, examId) {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
|
for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
|
||||||
@@ -107,19 +249,19 @@ function categoryKey(schoolId, code) {
|
|||||||
export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
||||||
const examId = setting.examId;
|
const examId = setting.examId;
|
||||||
const round = Number(setting.payload?.round || 1);
|
const round = Number(setting.payload?.round || 1);
|
||||||
const plans = approvedPlans(db, examId);
|
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||||
|
const plans = approvedPlans(db, examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
|
||||||
const categories = new Map();
|
const categories = new Map();
|
||||||
for (const plan of plans) for (const category of plan.payload?.categories || []) {
|
for (const plan of plans) for (const category of plan.payload?.categories || []) {
|
||||||
categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
|
categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn');
|
const allExisting = admissionRecords(db, 'placement', examId);
|
||||||
const occupied = new Map();
|
const existing = allExisting.filter(item => !['withdrawn', 'forfeited'].includes(item.status));
|
||||||
const occupiedIndicators = new Map();
|
const occupiedIndicators = new Map();
|
||||||
const occupiedGeneral = new Map();
|
const occupiedGeneral = new Map();
|
||||||
for (const placement of existing) {
|
for (const placement of existing) {
|
||||||
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
||||||
occupied.set(key, (occupied.get(key) || 0) + 1);
|
|
||||||
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
|
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
|
||||||
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
|
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
|
||||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
||||||
@@ -130,54 +272,78 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
|||||||
const candidates = preferences.map(preference => {
|
const candidates = preferences.map(preference => {
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||||
return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) };
|
const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved');
|
||||||
}).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)))
|
return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 };
|
||||||
.sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
|
}).filter(item => item.culturalScore != null && !allExisting.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(entry.status)));
|
||||||
|
|
||||||
const created = [];
|
const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || ''));
|
||||||
for (const candidate of candidates) {
|
const acceptedByBucket = new Map();
|
||||||
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
|
const queue = [...candidates].sort((left, right) => right.culturalScore - left.culturalScore || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
|
||||||
|
while (queue.length) {
|
||||||
|
const candidate = queue.shift();
|
||||||
|
const choices = candidate.preference.payload?.choices || [];
|
||||||
|
while (candidate.nextChoiceIndex < choices.length) {
|
||||||
|
const index = candidate.nextChoiceIndex;
|
||||||
|
const choice = choices[candidate.nextChoiceIndex++];
|
||||||
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const { category } = target;
|
const { category } = target;
|
||||||
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
|
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
|
||||||
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
||||||
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
|
|
||||||
let quotaBucket = null;
|
let quotaBucket = null;
|
||||||
|
let bucketKey = '';
|
||||||
|
let capacity = 0;
|
||||||
|
let occupiedCount = 0;
|
||||||
if (choice.preferenceType === 'indicator') {
|
if (choice.preferenceType === 'indicator') {
|
||||||
const qualification = indicatorQualification(db, examId, candidate.preference.userId);
|
const qualification = indicatorQualification(db, examId, candidate.preference.userId);
|
||||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
||||||
if (!qualification?.payload?.eligible || !allocation) continue;
|
if (!qualification?.payload?.eligible || !allocation) continue;
|
||||||
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
|
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
|
||||||
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
|
|
||||||
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
||||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
bucketKey = quotaBucket + '|' + key;
|
||||||
}
|
capacity = Number(allocation.quota || 0);
|
||||||
if (!quotaBucket) continue;
|
occupiedCount = occupiedIndicators.get(indicatorKey) || 0;
|
||||||
} else {
|
} else {
|
||||||
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
||||||
if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
|
|
||||||
quotaBucket = 'general';
|
quotaBucket = 'general';
|
||||||
occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
|
bucketKey = `general|${key}`;
|
||||||
|
capacity = generalQuota;
|
||||||
|
occupiedCount = occupiedGeneral.get(key) || 0;
|
||||||
}
|
}
|
||||||
occupied.set(key, (occupied.get(key) || 0) + 1);
|
const available = Math.max(0, capacity - occupiedCount);
|
||||||
created.push({
|
if (!available) continue;
|
||||||
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
|
||||||
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
const proposal = {
|
||||||
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
candidate, choice, category, index, quotaBucket,
|
||||||
totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0),
|
culturalScore: candidate.culturalScore,
|
||||||
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
featureScore: candidate.featureScore,
|
||||||
}
|
totalScore: Number((candidate.culturalScore + (usesFeatureScore ? candidate.featureScore : 0)).toFixed(2))
|
||||||
});
|
};
|
||||||
|
const accepted = acceptedByBucket.get(bucketKey) || [];
|
||||||
|
accepted.push(proposal);
|
||||||
|
accepted.sort(compareProposals);
|
||||||
|
const rejected = accepted.length > available ? accepted.pop() : null;
|
||||||
|
acceptedByBucket.set(bucketKey, accepted);
|
||||||
|
if (rejected && rejected !== proposal) queue.push(rejected.candidate);
|
||||||
|
if (rejected === proposal) continue;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return created;
|
|
||||||
|
const accepted = [...acceptedByBucket.values()].flat().sort(compareProposals);
|
||||||
|
return accepted.map(({ candidate, choice, category, index, quotaBucket, culturalScore, featureScore, totalScore }) => ({
|
||||||
|
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
||||||
|
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
||||||
|
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
||||||
|
culturalScore, totalScore, featureScore,
|
||||||
|
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remainingPlanQuota(db, plan) {
|
export function remainingPlanQuota(db, plan) {
|
||||||
return (plan.payload?.categories || []).map(category => {
|
return (plan.payload?.categories || []).map(category => {
|
||||||
const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn').length;
|
const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status)).length;
|
||||||
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+429
-3
@@ -152,10 +152,10 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; }
|
.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; }
|
||||||
.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; }
|
.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; }
|
||||||
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
.status-open, .status-published, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
.status-open, .status-published, .status-visible, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
||||||
.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
|
.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
|
||||||
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
|
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
|
||||||
.status-draft { color: #6d7589; background: #eef0f4; }
|
.status-draft, .status-hidden { color: #6d7589; background: #eef0f4; }
|
||||||
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
|
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
|
||||||
|
|
||||||
/* Public site */
|
/* Public site */
|
||||||
@@ -237,6 +237,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
||||||
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
||||||
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
||||||
|
.auth-session-notice { display:grid; gap:4px; margin:-12px 0 18px; padding:12px 14px; border-left:3px solid var(--red); border-radius:7px; color:#814b47; background:#fceceb; }.auth-session-notice strong { font-size:10px; }.auth-session-notice span { font-size:9px; line-height:1.7; }
|
||||||
|
|
||||||
/* Portal shell */
|
/* Portal shell */
|
||||||
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
||||||
@@ -254,6 +255,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
/* Admin */
|
/* Admin */
|
||||||
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
|
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
|
||||||
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
|
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
|
||||||
|
.notice-admin-toolbar { flex-wrap:wrap; }.notice-admin-toolbar > p { flex:1 0 100%; }.notice-row-actions { display:flex; align-items:center; gap:8px; }
|
||||||
.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; }
|
.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; }
|
||||||
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
|
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
|
||||||
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
|
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
|
||||||
@@ -267,6 +269,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
|
|
||||||
/* Modals and feedback */
|
/* Modals and feedback */
|
||||||
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
||||||
|
.fatal-error-actions { display:flex; gap:9px; }
|
||||||
.modal-card:has(.exam-config-form) { width:min(980px,100%); }
|
.modal-card:has(.exam-config-form) { width:min(980px,100%); }
|
||||||
.modal-card:has(.notice-editor-form) { width:min(820px,100%); }
|
.modal-card:has(.notice-editor-form) { width:min(820px,100%); }
|
||||||
.notice-editor-field { display:grid; gap:7px; }
|
.notice-editor-field { display:grid; gap:7px; }
|
||||||
@@ -370,6 +373,51 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; }
|
.result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; }
|
||||||
.result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; }
|
.result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; }
|
||||||
.result-excel-toolbar { margin-bottom:14px; }
|
.result-excel-toolbar { margin-bottom:14px; }
|
||||||
|
.result-entry-workbench { margin-bottom:14px; overflow:hidden; border-color:#cbd6e6; box-shadow:0 14px 34px rgba(20,36,81,.07); }
|
||||||
|
.result-workbench-head { display:grid; grid-template-columns:minmax(280px,1fr) minmax(430px,1.1fr); align-items:end; gap:28px; padding:22px 24px; color:#fff; background:linear-gradient(112deg,#12244d 0%,#1c4269 72%,#236b78 100%); }
|
||||||
|
.result-workbench-head > div:first-child > span { color:#8ed0d4; font:700 9px Consolas,monospace; letter-spacing:.18em; }
|
||||||
|
.result-workbench-head h2 { margin:6px 0 4px; font-family:"STKaiti","KaiTi",serif; font-size:25px; font-weight:400; }
|
||||||
|
.result-workbench-head p { margin:0; color:#c6d4e3; font-size:10px; line-height:1.7; }
|
||||||
|
.result-workbench-selectors { display:grid; grid-template-columns:1.25fr .75fr; gap:10px; }
|
||||||
|
.result-workbench-selectors label { display:grid; gap:6px; color:#dbe6f2; font-size:9px; font-weight:700; }
|
||||||
|
.result-workbench-selectors select { width:100%; min-height:42px; padding:8px 11px; border:1px solid rgba(255,255,255,.3); border-radius:8px; color:#15264b; background:#fff; font-size:11px; outline:0; }
|
||||||
|
.result-workbench-selectors select:focus { box-shadow:0 0 0 3px rgba(142,208,212,.24); }
|
||||||
|
.result-workbench-summary { display:grid; grid-template-columns:repeat(4,110px) 1fr; align-items:center; min-height:69px; padding:10px 24px; border-bottom:1px solid #e2e8f0; background:#f5f8fc; }
|
||||||
|
.result-workbench-summary > span { display:grid; gap:3px; border-right:1px solid #dce4ee; }
|
||||||
|
.result-workbench-summary small { color:#8a94a7; font-size:8px; }
|
||||||
|
.result-workbench-summary strong { color:#143a5b; font:400 19px Georgia,serif; }
|
||||||
|
.result-workbench-summary p { justify-self:end; margin:0; padding-left:18px; color:#60708a; font-size:10px; }
|
||||||
|
.result-entry-toolbar { min-height:62px; border-bottom:1px solid #e5eaf1; }
|
||||||
|
.result-entry-table-wrap { max-height:560px; overflow:auto; }
|
||||||
|
.result-entry-table-wrap table { min-width:1050px; }
|
||||||
|
.result-entry-table-wrap thead { position:sticky; top:0; z-index:3; }
|
||||||
|
.result-entry-table-wrap th { color:#56647c; background:#eef3f8; box-shadow:0 1px #dbe3ed; }
|
||||||
|
.result-entry-table-wrap tbody tr { transition:background .12s; }
|
||||||
|
.result-entry-table-wrap tbody tr:focus-within { background:#f1f7ff; }
|
||||||
|
.result-row-index { color:#97a1b1; text-align:center; }
|
||||||
|
.result-score-input-cell { min-width:170px; }
|
||||||
|
.result-score-input-cell input { width:126px; height:38px; padding:7px 11px; border:1px solid #cbd5e3; border-radius:7px; color:#102c4d; background:#fff; font:700 14px Consolas,monospace; outline:0; }
|
||||||
|
.result-score-input-cell input:focus { border-color:#315fba; box-shadow:0 0 0 3px rgba(49,95,186,.1); }
|
||||||
|
.result-score-input-cell input[data-dirty="true"] { border-color:#bd7d19; background:#fffaf0; }
|
||||||
|
.result-score-input-cell small { display:block; min-height:12px; margin-top:3px; color:#b23838; font-size:8px; }
|
||||||
|
.score-row-invalid { background:#fff6f5 !important; }
|
||||||
|
.score-row-invalid input { border-color:#c74646; }
|
||||||
|
.result-missing { display:inline-flex; padding:4px 8px; border-radius:99px; color:#7b8495; background:#eef1f5; font-size:8px; }
|
||||||
|
.result-workbench-actions { display:flex; align-items:center; justify-content:flex-end; gap:9px; min-height:74px; padding:13px 24px; border-top:1px solid #dfe6ef; background:#fff; box-shadow:0 -8px 20px rgba(20,36,81,.03); }
|
||||||
|
.result-workbench-actions > div { display:grid; gap:4px; margin-right:auto; }
|
||||||
|
.result-workbench-actions > div strong { color:#32435f; font-size:10px; }
|
||||||
|
.result-workbench-actions > div small { color:#8b95a7; font-size:8px; }
|
||||||
|
.feature-score-workbench { margin-bottom:14px; overflow:hidden; border-color:#bcd9dc; box-shadow:0 14px 34px rgba(25,86,95,.07); }
|
||||||
|
.feature-score-workbench > form > header { display:grid; grid-template-columns:minmax(300px,1fr) auto; align-items:center; gap:24px; padding:21px 24px; color:#133d48; background:linear-gradient(110deg,#eef9f8,#dceff0); border-left:4px solid #287486; }
|
||||||
|
.feature-score-workbench header > div:first-child > span { color:#287486; font:700 9px Consolas,monospace; letter-spacing:.18em; }
|
||||||
|
.feature-score-workbench h2 { margin:5px 0; font-family:"STKaiti","KaiTi",serif; font-size:24px; font-weight:400; }
|
||||||
|
.feature-score-workbench header p { max-width:720px; margin:0; color:#5b7780; font-size:10px; line-height:1.7; }
|
||||||
|
.feature-score-rule { display:grid; gap:5px; min-width:265px; padding:12px 15px; border:1px solid #b9d9da; border-radius:9px; background:rgba(255,255,255,.72); }
|
||||||
|
.feature-score-rule strong { color:#245b65; font-size:10px; }
|
||||||
|
.feature-score-rule span { color:#607a81; font-size:9px; }
|
||||||
|
.feature-score-input-cell input { width:140px; }
|
||||||
|
.feature-score-workbench td > small { display:block; margin-top:3px; color:#8a95a6; }
|
||||||
|
.feature-score-summary { grid-template-columns:repeat(4,125px) 1fr; }
|
||||||
.result-entry-console { margin-bottom:14px; overflow:hidden; }
|
.result-entry-console { margin-bottom:14px; overflow:hidden; }
|
||||||
.result-entry-console .panel-title { height:auto; min-height:74px; }
|
.result-entry-console .panel-title { height:auto; min-height:74px; }
|
||||||
.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; }
|
.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; }
|
||||||
@@ -586,11 +634,25 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
|
.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
|
||||||
.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
|
.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
|
||||||
.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
|
.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
|
||||||
|
.admission-admin-grid.single { grid-template-columns:1fr; }
|
||||||
.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
|
.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
|
||||||
.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
|
.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
|
||||||
.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
|
.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
|
||||||
.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; }
|
.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences > strong { margin-bottom:2px; color:#27384e; }.locked-preferences > span,.admin-preference-choices > span { display:flex; align-items:center; gap:12px; padding:11px 13px; border:1px solid #e2e8ee; border-radius:9px; background:#f7f9fb; }.locked-preferences b,.admin-preference-choices b { flex:0 0 35px; color:#287486; font-size:11px; text-align:center; }.locked-preferences i,.admin-preference-choices i { display:grid; gap:3px; font-style:normal; }.locked-preferences small,.admin-preference-choices small { color:#7d8998; }.admin-preference-choices { display:grid; gap:6px; min-width:300px; }
|
||||||
|
.read-only-callout.warning { border-left:3px solid #b27b2d; color:#75531f; background:#fff8e9; }
|
||||||
|
|
||||||
|
/* 录取结果通知沿用正式通知书的“签发栏”语汇,以状态与校名为阅读主线。 */
|
||||||
|
.admission-notification-stack { display:grid; gap:12px; margin-bottom:18px; }
|
||||||
|
.admission-notification { position:relative; display:grid; grid-template-columns:92px minmax(0,1fr) auto; overflow:hidden; border:1px solid #cad9df; border-radius:14px; color:#24354b; background:linear-gradient(105deg,#f5fafb 0 92px,#fff 92px); box-shadow:0 14px 34px rgba(23,55,95,.08); }
|
||||||
|
.admission-notification::after { content:""; position:absolute; inset:0 0 auto 92px; height:4px; background:linear-gradient(90deg,#287486,#d2ad62 70%,transparent); }
|
||||||
|
.admission-notification-mark { display:grid; align-content:space-between; justify-items:center; padding:20px 12px; color:#dcebed; background:#17375f; }.admission-notification-mark span { writing-mode:vertical-rl; color:#9fc8cd; font:700 10px Consolas,monospace; letter-spacing:.18em; }.admission-notification-mark strong { display:grid; place-items:center; width:34px; height:34px; border:1px solid rgba(255,255,255,.35); border-radius:50%; font:400 18px Georgia,serif; }
|
||||||
|
.admission-notification-copy { min-width:0; padding:23px 26px 20px; }.admission-notification-copy header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; }.admission-notification-copy header span { color:#287486; font-size:11px; font-weight:800; letter-spacing:.08em; }.admission-notification-copy h2 { margin:5px 0 0; font:700 24px/1.25 STKaiti,KaiTi,serif; }.admission-notification-copy time { color:#8793a2; font-size:11px; white-space:nowrap; }.admission-notification-copy > p { margin:12px 0 17px; color:#566478; }
|
||||||
|
.admission-notification-copy dl { display:flex; flex-wrap:wrap; gap:0; margin:0; border-top:1px solid #e2e8ed; }.admission-notification-copy dl div { min-width:150px; padding:12px 24px 0 0; }.admission-notification-copy dt { color:#9099a7; font-size:10px; }.admission-notification-copy dd { margin:4px 0 0; font-weight:700; }
|
||||||
|
.admission-notification-status { align-self:start; margin:22px 22px 0 0; padding:7px 11px; border:1px solid #a6cfc7; border-radius:999px; color:#17665b; background:#e7f5f1; font-size:11px; font-weight:800; white-space:nowrap; }
|
||||||
|
.admission-notification.invalid { border-color:#dfcecb; }.admission-notification.invalid::after { background:linear-gradient(90deg,#9c5c55,#d2ad62 70%,transparent); }.admission-notification.invalid .admission-notification-mark { background:#6f3f43; }.admission-notification.invalid .admission-notification-status { border-color:#e1c6c1; color:#8a4941; background:#faece9; }
|
||||||
|
@media (max-width:700px) { .admission-notification { grid-template-columns:58px minmax(0,1fr); }.admission-notification::after { left:58px; }.admission-notification-mark { padding:17px 8px; }.admission-notification-copy { padding:20px 16px; }.admission-notification-copy header { display:grid; gap:6px; }.admission-notification-status { grid-column:2; grid-row:2; justify-self:start; margin:0 16px 16px; }.admission-notification-copy dl { display:grid; grid-template-columns:1fr 1fr; }.admission-notification-copy dl div { min-width:0; } }
|
||||||
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
||||||
|
.placement-review-ledger { margin-bottom:16px; overflow:hidden; }.placement-review-ledger .data-toolbar { flex-wrap:wrap; }.placement-review-ledger table { min-width:1250px; }.placement-review-ledger td > small,.placement-review-ledger td > strong { display:block; margin-top:3px; }.placement-status-pills,.account-status-pills { margin:0 24px 12px; }.placement-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:0 24px 16px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }.placement-bulk-toolbar > label,.placement-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.placement-bulk-toolbar input { width:17px; height:17px; }.placement-bulk-toolbar strong { margin-right:5px; color:#287486; }.placement-bulk-toolbar button { min-height:40px; }
|
||||||
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
||||||
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
||||||
|
|
||||||
@@ -643,6 +705,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
|
|
||||||
/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */
|
/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */
|
||||||
.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; }
|
.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; }
|
||||||
|
.admission-account-management { margin-bottom:16px; }.admission-account-management > form { grid-template-columns:minmax(240px,1fr) minmax(380px,1.4fr) minmax(220px,.8fr) auto; align-items:end; }.admission-account-management > form > .field-row { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { min-width:170px; }.account-management-divider { display:flex; align-items:center; justify-content:space-between; padding:15px 24px; border-top:1px solid #e1e7ee; border-bottom:1px solid #e1e7ee; background:#f7f9fc; }.account-management-divider strong { color:#263a54; }.account-management-divider span { color:#8390a3; font-size:12px; }.admission-account-management .data-toolbar { padding-top:16px; padding-bottom:12px; }.admission-account-management table { min-width:980px; }
|
||||||
.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); }
|
.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); }
|
||||||
.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; }
|
.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; }
|
||||||
.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; }
|
.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; }
|
||||||
@@ -682,3 +745,366 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; }
|
.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; }
|
||||||
.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; }
|
.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; }
|
||||||
@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } }
|
@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } }
|
||||||
|
|
||||||
|
/* 审核工作台:以完整子页面承载资料、考试和处理结论 */
|
||||||
|
body.review-subpage-open { overflow:hidden; }
|
||||||
|
.review-subpage-layer { position:fixed; inset:0; z-index:120; overflow:auto; background:#eef2f6; animation:fadeIn .16s ease; }
|
||||||
|
.review-subpage { min-height:100vh; color:#27364a; background:linear-gradient(90deg,#f8fafc 0 72%,#edf2f5 72%); }
|
||||||
|
.review-subpage-header { min-height:190px; display:grid; grid-template-columns:minmax(190px,1fr) minmax(420px,2.2fr) minmax(140px,1fr); align-items:center; gap:28px; padding:34px clamp(28px,5vw,76px); color:#fff; background:linear-gradient(112deg,#132f52 0 62%,#216b79); box-shadow:0 12px 35px rgba(23,55,95,.16); }
|
||||||
|
.review-back { justify-self:start; align-self:start; padding:10px 0; border:0; color:#c5dbe4; background:transparent; font-size:14px; font-weight:700; cursor:pointer; }
|
||||||
|
.review-back:hover { color:#fff; }
|
||||||
|
.review-subpage-header > div > span { color:#9bd0d6; font:700 12px/1.3 Consolas,monospace; letter-spacing:.16em; }
|
||||||
|
.review-subpage-header h1 { margin:9px 0 12px; font:500 clamp(28px,3vw,42px)/1.18 STKaiti,KaiTi,serif; }
|
||||||
|
.review-subpage-header p { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0; color:#d2dce7; font-size:14px; }
|
||||||
|
.review-subpage-header p i { width:4px; height:4px; border-radius:50%; background:#7fa8b8; }
|
||||||
|
.review-subpage-header > .status { justify-self:end; min-width:82px; justify-content:center; padding:9px 14px; font-size:13px; }
|
||||||
|
.review-subpage-layout { width:min(1500px,100%); display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:28px; margin:0 auto; padding:32px clamp(24px,4vw,58px) 70px; }
|
||||||
|
.review-subpage-main { min-width:0; display:grid; align-content:start; gap:24px; }
|
||||||
|
.review-section { overflow:hidden; border:1px solid #dbe3ea; border-radius:14px; background:#fff; box-shadow:0 9px 28px rgba(28,52,76,.05); }
|
||||||
|
.review-section > header { min-height:86px; display:flex; align-items:center; gap:16px; padding:20px 24px; border-bottom:1px solid #e3e9ee; background:linear-gradient(90deg,#fff,#f6f9fa); }
|
||||||
|
.review-section > header > span { min-width:45px; height:34px; display:grid; place-items:center; border-radius:6px; color:#fff; background:#245783; font:700 12px Consolas,monospace; }
|
||||||
|
.review-section > header > div { min-width:0; margin-right:auto; }
|
||||||
|
.review-section > header h2 { margin:0; color:#21364e; font-size:21px; }
|
||||||
|
.review-section > header p { margin:5px 0 0; color:#748396; font-size:14px; }
|
||||||
|
.review-section > header > strong { color:#287486; font-size:15px; }
|
||||||
|
.review-detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; margin:0; background:#e5eaef; }
|
||||||
|
.review-detail-grid > div { min-width:0; padding:17px 20px; background:#fff; }
|
||||||
|
.review-detail-grid > div.wide { grid-column:1/-1; }
|
||||||
|
.review-detail-grid dt { margin-bottom:7px; color:#798799; font-size:13px; }
|
||||||
|
.review-detail-grid dd { margin:0; overflow-wrap:anywhere; color:#293b51; font-size:15px; line-height:1.65; }
|
||||||
|
.review-exam-list { display:grid; gap:14px; padding:20px 22px 24px; }
|
||||||
|
.review-exam-card { overflow:hidden; border:1px solid #dfe6eb; border-radius:11px; background:#fbfcfd; }
|
||||||
|
.review-exam-card > header { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border-bottom:1px solid #e4e9ed; background:#fff; }
|
||||||
|
.review-exam-card h3 { margin:5px 0 0; font-size:18px; }
|
||||||
|
.review-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:0; margin:0; }
|
||||||
|
.review-exam-card dl > div { padding:15px 18px; border-top:1px solid #e8edf0; }
|
||||||
|
.review-exam-card dl > div:nth-child(-n+2) { border-top:0; }
|
||||||
|
.review-exam-card dt { color:#7d8997; font-size:13px; }
|
||||||
|
.review-exam-card dd { margin:6px 0 0; color:#33465a; font-size:14px; line-height:1.6; }
|
||||||
|
.review-subject-list { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
|
.review-subject-list > span { display:grid; gap:3px; padding:9px 11px; border:1px solid #d9e3e7; border-radius:8px; background:#fff; }
|
||||||
|
.review-subject-list small { color:#738395; font-size:12px; }
|
||||||
|
.review-empty-context { padding:25px; border:1px dashed #cdd8df; border-radius:10px; color:#67788b; background:#f7fafb; text-align:center; }
|
||||||
|
.review-empty-context strong { color:#33485e; font-size:16px; }
|
||||||
|
.review-empty-context p { margin:7px 0 0; font-size:14px; }
|
||||||
|
.review-decision-form,.review-readonly { position:sticky; top:24px; align-self:start; display:grid; gap:18px; padding:24px; border-top:5px solid #287486; border-radius:12px; background:#fff; box-shadow:0 14px 38px rgba(26,52,76,.12); }
|
||||||
|
.review-decision-form > div { display:grid; gap:5px; padding-bottom:18px; border-bottom:1px solid #e1e7eb; }
|
||||||
|
.review-decision-form > div span,.review-decision-form label > span { color:#708093; font-size:13px; font-weight:700; }
|
||||||
|
.review-decision-form > div strong { color:#1f3853; font-size:18px; }
|
||||||
|
.review-decision-form > div small { color:#7e8a99; font-size:13px; }
|
||||||
|
.review-decision-form label { display:grid; gap:7px; }
|
||||||
|
.review-decision-form select,.review-decision-form textarea { width:100%; padding:11px 12px; border:1px solid #cbd6df; border-radius:8px; color:#293b50; background:#fff; font-size:14px; line-height:1.6; }
|
||||||
|
.review-decision-form select:focus,.review-decision-form textarea:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); }
|
||||||
|
.review-decision-form .solid-button { min-height:46px; font-size:14px; }
|
||||||
|
.review-readonly strong { color:#233c56; font-size:18px; }.review-readonly p { margin:0; color:#697b8e; font-size:14px; line-height:1.7; }
|
||||||
|
.exam-context-hero { border-top:5px solid #245783; }
|
||||||
|
.subject-review-section > footer { display:flex; align-items:baseline; justify-content:flex-end; gap:14px; padding:18px 24px; border-top:1px solid #e1e7eb; background:#f8fafb; }
|
||||||
|
.subject-review-section > footer span { color:#6d7e91; font-size:14px; }.subject-review-section > footer strong { color:#17375f; font-size:24px; }
|
||||||
|
.review-subject-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:13px; padding:20px 22px; }
|
||||||
|
.review-subject-cards > article { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:12px; padding:16px; border:1px solid #dbe4ea; border-radius:10px; background:#fff; }
|
||||||
|
.review-subject-cards > article > i { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#287486; font-size:13px; font-style:normal; }
|
||||||
|
.review-subject-cards > article > div { display:grid; gap:4px; min-width:0; }.review-subject-cards > article > div strong { font-size:16px; }.review-subject-cards > article > div span { color:#718194; font-size:13px; }
|
||||||
|
.review-subject-cards dl { display:flex; gap:16px; margin:0; }.review-subject-cards dl div { display:grid; gap:4px; }.review-subject-cards dt { color:#8290a0; font-size:12px; }.review-subject-cards dd { margin:0; font-size:14px; font-weight:700; }
|
||||||
|
|
||||||
|
/* 高频台账筛选与批量操作 */
|
||||||
|
.candidate-toolbar { flex-wrap:wrap; }.table-filter-selects.five-columns { grid-template-columns:repeat(5,minmax(120px,1fr)); }
|
||||||
|
.candidate-exam-summary { min-width:190px; }.candidate-exam-summary strong,.candidate-exam-summary small { display:block; }.candidate-exam-summary span { color:#8793a2; }
|
||||||
|
.registration-exam-cell { min-width:220px; }.subject-summary { display:flex; flex-wrap:wrap; gap:5px; margin:7px 0 4px; }.subject-summary span { padding:4px 7px; border-radius:5px; color:#36576e; background:#e9f1f4; font-size:12px; font-weight:700; }.subject-summary em { color:#a14c43; font-style:normal; }
|
||||||
|
.qualification-filter-toolbar { flex-wrap:wrap; margin-top:14px; border-top:1px solid #e4e9ed; }.qualification-specialty-filter { flex:1 0 100%; display:flex; gap:9px; }.qualification-specialty-filter select { flex:1; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }.qualification-specialty-filter .row-action { padding:0 12px; border:1px solid #d6dfe5; border-radius:7px; background:#fff; }
|
||||||
|
.workflow-filter-toolbar { flex-wrap:wrap; margin-bottom:16px; }.workflow-type-filter { flex:1 0 100%; }.workflow-type-filter select { width:100%; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }
|
||||||
|
|
||||||
|
/* 全站可读性下限:正文 14px,辅助信息不低于 12px */
|
||||||
|
.admin-readable .portal-sidebar nav button { font-size:14px; }.admin-readable .portal-sidebar nav button em { font-size:12px; }
|
||||||
|
.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:14px; }
|
||||||
|
.admin-readable .portal-user > span small,.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:12px; }
|
||||||
|
.admin-readable .portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||||
|
.admin-readable .portal-content small { font-size:12px !important; line-height:1.55; }
|
||||||
|
.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }
|
||||||
|
.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:14px !important; }
|
||||||
|
.admin-readable .portal-content .status,.admin-readable .portal-content .overline,.admin-readable .portal-content .exam-code { font-size:12px !important; }
|
||||||
|
.admin-readable .portal-content :where(.solid-button,.ghost-button,.row-action) { min-height:38px; }
|
||||||
|
.portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||||
|
.portal-content small { font-size:12px !important; line-height:1.55; }.portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }.portal-content .status,.portal-content .overline,.portal-content .exam-code { font-size:12px !important; }
|
||||||
|
.portal-sidebar nav button { font-size:14px; }.portal-sidebar nav button em,.portal-user small,.sidebar-help small { font-size:12px; }.portal-topbar button { font-size:13px; }
|
||||||
|
.auth-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.auth-page span:not(:has(svg)) { font-size:14px; }.auth-page small { font-size:12px; }
|
||||||
|
.onboarding-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.onboarding-page span:not(:has(svg)) { font-size:14px; }.onboarding-page small { font-size:12px; }
|
||||||
|
.modal-card :where(p,dt,dd,label > span,button,input,select,textarea,li) { font-size:14px; line-height:1.65; }.modal-card span:not(:has(svg)) { font-size:14px; }.modal-card small,.modal-head span { font-size:12px; }
|
||||||
|
.public-footer p,.public-footer > span { font-size:13px; line-height:1.65; }
|
||||||
|
.notice-document table th,.notice-document table td { padding:15px 17px; font-size:15px; line-height:1.55; }
|
||||||
|
.notice-document .document-lead { font-size:15px; }.notice-document .qualification-result { min-width:46px; padding:6px 11px; font-size:14px; }
|
||||||
|
.public-main :where(button,input,select,textarea) { font-size:14px; }.public-main :where(p,dt,dd) { font-size:14px; line-height:1.7; }.public-main span:not(:has(svg)):not(.status) { font-size:14px; }.public-main small { font-size:12px; line-height:1.55; }
|
||||||
|
|
||||||
|
@media (max-width:1000px) {
|
||||||
|
.review-subpage { background:#f4f7f9; }.review-subpage-header { grid-template-columns:1fr auto; min-height:0; }.review-subpage-header > div { grid-column:1/-1; grid-row:2; }.review-subpage-header > .status { grid-column:2; grid-row:1; }.review-subpage-layout { grid-template-columns:1fr; }.review-decision-form,.review-readonly { position:static; }.table-filter-selects.five-columns { grid-template-columns:repeat(2,minmax(130px,1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width:650px) {
|
||||||
|
.review-subpage-header { padding:24px 20px; }.review-subpage-layout { padding:20px 14px 45px; }.review-detail-grid,.review-exam-card dl { grid-template-columns:1fr; }.review-detail-grid > div.wide { grid-column:auto; }.review-exam-card dl > div:nth-child(2) { border-top:1px solid #e8edf0; }.review-subject-cards { grid-template-columns:1fr; padding:14px; }.review-subject-cards > article { grid-template-columns:32px minmax(0,1fr); }.review-subject-cards dl { grid-column:2; }.qualification-specialty-filter { flex-direction:column; }.table-filter-selects.five-columns { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width:1000px) {
|
||||||
|
.result-workbench-head,.feature-score-workbench > form > header { grid-template-columns:1fr; }.result-workbench-summary { grid-template-columns:repeat(4,1fr); }.result-workbench-summary p { grid-column:1/-1; justify-self:start; padding:8px 0 0; }.feature-score-rule { min-width:0; }
|
||||||
|
}
|
||||||
|
@media (max-width:650px) {
|
||||||
|
.result-workbench-selectors,.result-workbench-summary { grid-template-columns:1fr; }.result-workbench-summary > span { padding:6px 0; border-right:0; border-bottom:1px solid #e2e8f0; }.result-workbench-actions { align-items:stretch; flex-direction:column; }.result-workbench-actions > div { margin:0 0 5px; }.result-workbench-actions button { width:100%; }
|
||||||
|
}
|
||||||
|
@media (max-width:1100px) {
|
||||||
|
.admission-account-management > form { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { width:100%; }
|
||||||
|
}
|
||||||
|
@media (max-width:800px) {
|
||||||
|
.admission-account-management > form { grid-template-columns:1fr; }.placement-bulk-toolbar,.placement-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.placement-bulk-toolbar button { width:100%; }.placement-status-pills,.account-status-pills { margin-right:18px; margin-left:18px; }
|
||||||
|
}
|
||||||
|
.table-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-top: 1px solid var(--line, #e5e7eb);
|
||||||
|
color: var(--muted, #64748b);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-pagination > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-pagination button,
|
||||||
|
.table-pagination select {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--line, #dbe2ea);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-pagination button:not(:disabled) { cursor: pointer; }
|
||||||
|
.table-pagination button:hover:not(:disabled) { border-color: #1f6f5f; color: #1f6f5f; }
|
||||||
|
.table-pagination button.active { border-color: #1f6f5f; background: #1f6f5f; color: #fff; }
|
||||||
|
.table-pagination button:disabled { opacity: .45; }
|
||||||
|
.table-pagination label { display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; }
|
||||||
|
.table-pagination i { font-style: normal; padding: 0 2px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.table-pagination { align-items: flex-start; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 业务域分组导航 */
|
||||||
|
.portal-sidebar { width: 264px; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #42547f transparent; }
|
||||||
|
.portal-main { margin-left: 264px; }
|
||||||
|
.portal-nav-groups { display: grid; gap: 15px !important; padding: 3px 0 18px; }
|
||||||
|
.portal-nav-group { display: grid; gap: 4px; }
|
||||||
|
.portal-nav-group > strong { padding: 0 13px 4px; color: #7181aa; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
|
||||||
|
.portal-nav-group button { min-height: 38px !important; }
|
||||||
|
.sidebar-help { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* 成绩卡:排名信息回归文档流,永不覆盖科目标题 */
|
||||||
|
.score-grid { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
|
||||||
|
.score-grid article { min-width: 0; place-content: initial; align-content: start; justify-items: stretch; min-height: 230px; border: 1px solid var(--line); border-width: 0 1px 1px 0; }
|
||||||
|
.score-subject-head { width: 100%; min-height: 31px; }
|
||||||
|
.score-subject-head > span { overflow: hidden; color: var(--navy); font-size: 14px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.score-grid article > strong { margin: 22px 0 8px; font-size: 40px; }
|
||||||
|
.score-grid article > em { position: static; display: block; padding: 0; color: #526079; background: transparent; font-size: 12px; font-style: normal; line-height: 1.55; }
|
||||||
|
.rank-rule-line { align-items: flex-start; flex-direction: column; gap: 5px; margin-top: auto; }
|
||||||
|
.rank-rule-line span { font-size: 11px; }
|
||||||
|
.rank-rule-line b { font-size: 12px; line-height: 1.45; }
|
||||||
|
.result-footer-actions { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.result-footer-actions .solid-button { min-height: 34px; padding: 0 14px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* 通知书模板工作室 */
|
||||||
|
.notice-template-studio { display: grid; grid-template-columns: minmax(380px, .82fr) minmax(420px, 1.18fr); gap: 22px; align-items: start; }
|
||||||
|
.notice-template-form { padding: 24px; }
|
||||||
|
.notice-template-form .panel-title { margin-bottom: 20px; }
|
||||||
|
.notice-template-form > label { display: grid; gap: 7px; margin-bottom: 16px; }
|
||||||
|
.notice-template-form textarea { resize: vertical; }
|
||||||
|
.template-color-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 18px 0; }
|
||||||
|
.template-color-row label { display: flex; align-items: center; justify-content: space-between; padding: 12px; border: 1px solid var(--line); border-radius: 9px; }
|
||||||
|
.template-color-row input { width: 64px; height: 34px; padding: 2px; }
|
||||||
|
.notice-template-preview { position: sticky; top: 20px; }
|
||||||
|
.template-frame { position: relative; min-height: 720px; padding: 66px 64px; border: 8px solid var(--template-primary); outline: 2px solid var(--template-accent); outline-offset: -18px; color: #332f2c; background: #fffdf8; box-shadow: var(--shadow); }
|
||||||
|
.template-frame > small { display: block; color: var(--template-accent); font-family: Consolas, monospace; letter-spacing: .18em; text-align: center; }
|
||||||
|
.template-frame h2 { margin: 26px 0 12px; color: var(--template-primary); font-family: "STKaiti", serif; font-size: 45px; font-weight: 700; letter-spacing: .16em; text-align: center; }
|
||||||
|
.template-frame h3 { margin: 0 0 70px; text-align: center; }
|
||||||
|
.template-frame > strong { font-size: 18px; }
|
||||||
|
.template-frame > p { min-height: 250px; margin: 24px 0; font-size: 16px; line-height: 2.2; white-space: pre-wrap; }
|
||||||
|
.template-frame footer { display: grid; justify-items: end; gap: 12px; margin-top: 40px; }
|
||||||
|
.template-frame footer span { justify-self: stretch; color: #786f65; }
|
||||||
|
.notice-template-preview > p { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||||
|
|
||||||
|
/* 公开防伪查询 */
|
||||||
|
.verification-page { min-height: calc(100vh - 76px); padding: 72px max(24px, calc((100% - 1060px) / 2)); background: #f4f7fa; }
|
||||||
|
.verification-hero { display: grid; grid-template-columns: 1fr 470px; gap: 70px; align-items: end; padding: 48px; border-radius: 18px; color: #fff; background: var(--navy); box-shadow: 0 26px 70px rgba(19,36,81,.2); }
|
||||||
|
.verification-hero h1 { margin: 10px 0; font-family: "STKaiti", serif; font-size: 44px; font-weight: 400; }
|
||||||
|
.verification-hero p { color: #abb7d3; }
|
||||||
|
.verification-hero form { display: flex; align-items: end; gap: 10px; }
|
||||||
|
.verification-hero label { display: grid; flex: 1; gap: 8px; color: #bdc8df; font-size: 12px; }
|
||||||
|
.verification-hero input { height: 46px; border-color: rgba(255,255,255,.18); color: #fff; background: rgba(255,255,255,.08); font-family: Consolas, monospace; }
|
||||||
|
.verification-result { display: grid; grid-template-columns: 64px 1fr; gap: 22px; margin-top: 24px; padding: 32px; border: 1px solid #cfe1db; border-radius: 15px; background: #fff; }
|
||||||
|
.verification-result > span { width: 56px; height: 56px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #28735e; font-size: 27px; }
|
||||||
|
.verification-result h2 { margin: 5px 0; }
|
||||||
|
.verification-result p { color: var(--muted); }
|
||||||
|
.verification-result dl { grid-column: 1/-1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 4px 0 0; }
|
||||||
|
.verification-result dl div { padding: 14px; border-radius: 8px; background: #f3f6f8; }
|
||||||
|
.verification-result dt { color: var(--muted); font-size: 10px; }
|
||||||
|
.verification-result dd { margin: 5px 0 0; font-weight: 700; }
|
||||||
|
.verification-result.invalid { border-color: #efcfcb; }
|
||||||
|
.verification-result.invalid > span { background: #b94b44; }
|
||||||
|
.verification-notice { margin-top: 20px; padding: 20px 24px; border-left: 3px solid #8792a8; background: #fff; }
|
||||||
|
.verification-notice p { margin: 6px 0 0; color: var(--muted); }
|
||||||
|
.admission-result-banner .solid-button { justify-self: start; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* 招生计划完成率与考生报到工作台 */
|
||||||
|
.admission-progress-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:14px; margin:0 0 22px; }
|
||||||
|
.admission-progress-grid article { padding:20px; border:1px solid #d8e4e2; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,35,75,.06); }
|
||||||
|
.admission-progress-grid header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
|
||||||
|
.admission-progress-grid header span { color:#53627a; font-size:12px; }
|
||||||
|
.admission-progress-grid header strong { color:#1f6f5f; font-size:27px; }
|
||||||
|
.progress-meter { height:8px; margin:14px 0 12px; overflow:hidden; border-radius:99px; background:#e5eceb; }
|
||||||
|
.progress-meter i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,#1f6f5f,#67a58f); }
|
||||||
|
.admission-progress-grid p { margin:0; color:#273650; }
|
||||||
|
.admission-progress-grid small { color:#7d8798; }
|
||||||
|
.reporting-workbench { margin-bottom:24px; border:1px solid #d9e2e8; border-radius:15px; overflow:hidden; background:#fff; box-shadow:0 16px 40px rgba(20,35,75,.07); }
|
||||||
|
.reporting-workbench > header { display:flex; align-items:flex-end; justify-content:space-between; gap:28px; padding:26px 28px 22px; color:#fff; background:linear-gradient(115deg,#172d55,#214f65); }
|
||||||
|
.reporting-workbench > header > div:first-child { min-width:0; }
|
||||||
|
.reporting-workbench > header span { color:#9fb5c9; font:11px Consolas,monospace; letter-spacing:.08em; }
|
||||||
|
.reporting-workbench > header h2 { margin:7px 0 5px; font-size:25px; }
|
||||||
|
.reporting-workbench > header p { margin:0; color:#c4d2df; }
|
||||||
|
.reporting-rate { flex:0 0 auto; text-align:right; }
|
||||||
|
.reporting-rate strong { display:block; color:#f1d28a; font-size:42px; line-height:1; }
|
||||||
|
.reporting-rate span { color:#bdcbd7 !important; font-family:inherit !important; letter-spacing:0 !important; }
|
||||||
|
.reporting-stat-strip { display:flex; align-items:center; gap:24px; padding:15px 28px; border-bottom:1px solid #e3e9ed; background:#f7f9fa; }
|
||||||
|
.reporting-stat-strip span { color:#667085; }
|
||||||
|
.reporting-stat-strip b { margin-left:5px; color:#172d55; font-size:17px; }
|
||||||
|
.reporting-stat-strip em { margin-left:auto; padding:5px 10px; border-radius:99px; color:#1f6f5f; background:#dfeee9; font-style:normal; font-weight:700; }
|
||||||
|
.reporting-tools { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:stretch; margin:20px 24px 0; }
|
||||||
|
.reporting-excel-tool,.reporting-scan-tool { display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center; padding:17px; border:1px solid #ead9a5; border-radius:10px; background:#fffaf0; }
|
||||||
|
.reporting-scan-tool { border-color:#bcd8d0; background:#f3faf7; }
|
||||||
|
.reporting-tools > div > div:first-child { display:grid; gap:3px; }
|
||||||
|
.reporting-tools small { color:#7d735e; }
|
||||||
|
.reporting-tools form { grid-column:1/-1; display:flex; gap:8px; }
|
||||||
|
.reporting-tools form input { min-width:220px; }
|
||||||
|
.tool-buttons { display:flex; gap:8px; }
|
||||||
|
.reporting-import-summary { grid-column:1/-1; display:grid; grid-template-columns:1fr auto; gap:4px 14px; padding:10px 12px; border-left:3px solid #1f6f5f; border-radius:6px; color:#315d52; background:#e7f3ee; }
|
||||||
|
.reporting-import-summary.unchanged { border-left-color:#9a7a38; color:#6e5b33; background:#f8efd9; }
|
||||||
|
.reporting-import-summary small { grid-column:1/-1; }
|
||||||
|
.camera-button { min-height:42px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:0 16px; border:0; border-radius:8px; color:#fff; background:#1f6f5f; font-weight:700; cursor:pointer; box-shadow:0 8px 18px rgba(31,111,95,.2); }
|
||||||
|
.qr-capture { min-height:38px; display:inline-flex; align-items:center; justify-content:center; padding:0 13px; border:1px dashed #1f6f5f; border-radius:8px; color:#1f6f5f; cursor:pointer; }
|
||||||
|
.reporting-workbench > form,.reporting-ledger-readonly { padding:20px 24px 24px; }
|
||||||
|
.reporting-workbench table select,.reporting-workbench table input { min-width:150px; }
|
||||||
|
.reporting-workbench table input { width:100%; }
|
||||||
|
.reporting-workbench table .selection-cell { width:56px; min-width:56px; text-align:center; }
|
||||||
|
.reporting-workbench table .selection-cell input { width:17px; min-width:17px; height:17px; }
|
||||||
|
.reporting-bulk-bar { display:grid; grid-template-columns:auto auto minmax(180px,.7fr) minmax(260px,1fr) auto; gap:12px; align-items:end; margin:14px 0; padding:14px; border:1px solid #d5e1e5; border-radius:9px; background:#f7f9fb; }
|
||||||
|
.reporting-bulk-bar label:not(.bulk-check) { display:grid; gap:5px; }
|
||||||
|
.reporting-bulk-bar label span { color:#667085; font-size:11px; }
|
||||||
|
.reporting-bulk-bar .bulk-check { display:flex; align-items:center; gap:7px; min-height:40px; }
|
||||||
|
.reporting-bulk-bar .bulk-check input { width:17px; height:17px; }
|
||||||
|
.reporting-bulk-bar > strong { min-height:40px; display:flex; align-items:center; color:#1f6f5f; white-space:nowrap; }
|
||||||
|
.reporting-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:18px; }
|
||||||
|
.reporting-decision { display:grid; grid-template-columns:1.2fr .7fr 1fr auto; gap:14px; align-items:end; margin:0; padding:22px 24px; border-top:1px solid #e3e9ed; background:#f7faf9; }
|
||||||
|
.reporting-decision label { display:grid; gap:6px; }
|
||||||
|
.reporting-decision p { margin:4px 0 0; color:#6b7688; }
|
||||||
|
.reporting-readonly-note { padding:20px 24px; border-top:1px solid #e3e9ed; background:#f7f9fb; }
|
||||||
|
.reporting-readonly-note p { margin:5px 0 0; color:#6f7a8d; }
|
||||||
|
.reporting-camera-head,.reporting-confirm-head { border-bottom:1px solid #dce5e8; }
|
||||||
|
.reporting-camera-stage { position:relative; margin:22px 24px 10px; overflow:hidden; aspect-ratio:16/10; border-radius:13px; background:#0c1725; }
|
||||||
|
.reporting-camera-stage video { width:100%; height:100%; display:block; object-fit:cover; }
|
||||||
|
.scan-frame { position:absolute; inset:15% 25%; display:grid; place-items:end center; padding:14px; color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.48)); }
|
||||||
|
.scan-frame i { position:absolute; width:36px; height:36px; border-color:#f1d28a; border-style:solid; }
|
||||||
|
.scan-frame i:nth-child(1) { top:0; left:0; border-width:3px 0 0 3px; }
|
||||||
|
.scan-frame i:nth-child(2) { top:0; right:0; border-width:3px 3px 0 0; }
|
||||||
|
.scan-frame i:nth-child(3) { bottom:0; left:0; border-width:0 0 3px 3px; }
|
||||||
|
.scan-frame i:nth-child(4) { right:0; bottom:0; border-width:0 3px 3px 0; }
|
||||||
|
.scan-frame span { font-size:12px; }
|
||||||
|
.reporting-camera-status { margin:0 24px 14px; color:#315d52; }
|
||||||
|
.reporting-camera-status.error { color:#a0473f; }
|
||||||
|
.reporting-camera-fallback { display:grid; grid-template-columns:1fr auto; gap:10px; margin:0 24px 24px; padding-top:14px; border-top:1px solid #e1e7ea; }
|
||||||
|
.reporting-camera-fallback form { display:flex; gap:8px; }
|
||||||
|
.reporting-camera-fallback form input { flex:1; }
|
||||||
|
.reporting-confirm-form { padding:22px 24px 24px; }
|
||||||
|
.reporting-candidate-card { position:relative; padding:20px; border:1px solid #cfe1db; border-radius:11px; background:#f4faf7; }
|
||||||
|
.reporting-candidate-card dl { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin:0; padding-right:90px; }
|
||||||
|
.reporting-candidate-card dt { color:#748078; font-size:11px; }
|
||||||
|
.reporting-candidate-card dd { margin:4px 0 0; color:#173f3a; font-weight:700; }
|
||||||
|
.candidate-stamp { position:absolute; top:20px; right:18px; padding:7px 10px; border:2px solid #28735e; border-radius:5px; color:#28735e; font-weight:800; transform:rotate(-4deg); }
|
||||||
|
.reporting-decision-options { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:20px 0; padding:0; border:0; }
|
||||||
|
.reporting-decision-options legend { margin-bottom:9px; color:#29364b; font-weight:700; }
|
||||||
|
.reporting-decision-options label { display:flex; gap:10px; padding:14px; border:1px solid #d7e0e4; border-radius:9px; cursor:pointer; }
|
||||||
|
.reporting-decision-options label.selected { border-color:#1f6f5f; background:#edf7f3; box-shadow:inset 0 0 0 1px #1f6f5f; }
|
||||||
|
.reporting-decision-options input { margin-top:3px; }
|
||||||
|
.reporting-decision-options strong,.reporting-decision-options small { display:block; }
|
||||||
|
.reporting-decision-options small { margin-top:4px; color:#788393; }
|
||||||
|
.reporting-confirm-note { display:grid; gap:7px; }
|
||||||
|
.workflow-hint { display:block; margin-top:12px; color:#7a8495; line-height:1.6; }
|
||||||
|
.template-notice-number { margin:36px 0 28px; color:#6d655b; font:12px Consolas,monospace; text-align:right; }
|
||||||
|
.template-qr-placeholder { position:absolute; right:52px; bottom:48px; width:88px; height:88px; display:grid; place-items:center; border:1px dashed var(--template-primary); color:var(--template-primary); font-size:10px; text-align:center; }
|
||||||
|
.reporting-public-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:22px 0; }
|
||||||
|
.reporting-public-stats article { padding:18px; border:1px solid #dce5e8; border-radius:9px; background:#f7faf9; }
|
||||||
|
.reporting-public-stats span,.reporting-public-stats small { display:block; color:#758093; }
|
||||||
|
.reporting-public-stats strong { display:inline-block; margin:8px 4px 2px 0; color:#173f60; font-size:28px; }
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.notice-template-studio,.verification-hero { grid-template-columns: 1fr; }
|
||||||
|
.notice-template-preview { position: static; }
|
||||||
|
.reporting-tools { grid-template-columns:1fr; }
|
||||||
|
.reporting-bulk-bar { grid-template-columns:auto auto 1fr; }
|
||||||
|
.reporting-bulk-bar .bulk-note { grid-column:1/3; }
|
||||||
|
.reporting-decision { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.portal-sidebar { width: 264px; }
|
||||||
|
.portal-main { margin-left: 0; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.score-grid { grid-template-columns: 1fr; }
|
||||||
|
.result-footer-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||||
|
.verification-page { padding: 28px 14px; }
|
||||||
|
.verification-hero { padding: 26px 20px; }
|
||||||
|
.verification-hero form { align-items: stretch; flex-direction: column; }
|
||||||
|
.verification-result dl { grid-template-columns: 1fr; }
|
||||||
|
.template-frame { min-height: 620px; padding: 48px 34px; }
|
||||||
|
.reporting-workbench > header { align-items:flex-start; flex-direction:column; }
|
||||||
|
.reporting-rate { text-align:left; }
|
||||||
|
.reporting-stat-strip { align-items:flex-start; flex-direction:column; gap:8px; }
|
||||||
|
.reporting-stat-strip em { margin-left:0; }
|
||||||
|
.reporting-tools,.reporting-decision,.reporting-public-stats { grid-template-columns:1fr; }
|
||||||
|
.reporting-tools form { grid-template-columns:1fr; }
|
||||||
|
.reporting-excel-tool,.reporting-scan-tool,.reporting-camera-fallback,.reporting-bulk-bar,.reporting-decision-options { grid-template-columns:1fr; }
|
||||||
|
.tool-buttons { align-items:stretch; flex-direction:column; }
|
||||||
|
.reporting-bulk-bar .bulk-note { grid-column:auto; }
|
||||||
|
.reporting-camera-fallback form { display:grid; grid-template-columns:1fr; }
|
||||||
|
.reporting-candidate-card dl { grid-template-columns:1fr; padding-right:0; }
|
||||||
|
.candidate-stamp { position:static; display:inline-block; margin-bottom:16px; }
|
||||||
|
.reporting-actions { align-items:stretch; flex-direction:column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.admission-snapshot-console { overflow:hidden; padding:0; }
|
||||||
|
.admission-snapshot-console > .panel-title { padding:22px 24px 18px; border-bottom:1px solid #dfe7eb; background:linear-gradient(110deg,#f4f8fa,#eef6f3); }
|
||||||
|
.snapshot-ledger-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px; padding:18px 24px 24px; }
|
||||||
|
.snapshot-ledger-grid article { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:16px 22px; align-items:center; padding:18px; border:1px solid #dce5e8; border-radius:11px; background:#fff; box-shadow:0 6px 18px rgba(28,55,73,.04); }
|
||||||
|
.snapshot-ledger-grid article > div > span { color:#2a7280; font:700 11px Consolas,monospace; letter-spacing:.08em; }
|
||||||
|
.snapshot-ledger-grid h3 { margin:5px 0 4px; color:#18334d; font-size:17px; }
|
||||||
|
.snapshot-ledger-grid p { margin:0; color:#758192; font-size:12px; }
|
||||||
|
.snapshot-ledger-grid dl { display:grid; grid-template-columns:repeat(4,minmax(58px,1fr)); grid-column:1/-1; gap:8px; margin:0; }
|
||||||
|
.snapshot-ledger-grid dl div { padding:10px 11px; border-radius:8px; background:#f4f7f8; }
|
||||||
|
.snapshot-ledger-grid dt { color:#788492; font-size:11px; }
|
||||||
|
.snapshot-ledger-grid dd { margin:4px 0 0; color:#173e4a; font-size:20px; font-weight:800; }
|
||||||
|
.snapshot-ledger-grid article > button { grid-column:2; grid-row:1; }
|
||||||
|
.admission-ledger-toolbar { align-items:center; flex-wrap:wrap; }
|
||||||
|
.admission-ledger-toolbar .search-box,.placement-supervision-toolbar .search-box { flex:1 1 320px; }
|
||||||
|
.admission-ledger-toolbar .filter-pills,.placement-supervision-toolbar .filter-pills { flex:1 1 100%; }
|
||||||
|
.ledger-toolbar-actions { display:flex; gap:8px; margin-left:auto; }
|
||||||
|
.preference-empty { display:block; padding:13px; border:1px dashed #cdd8de; border-radius:8px; color:#7b8794; font-style:normal; text-align:center; background:#fafcfc; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.snapshot-ledger-grid { grid-template-columns:1fr; padding:14px; }
|
||||||
|
.snapshot-ledger-grid article { grid-template-columns:1fr; }
|
||||||
|
.snapshot-ledger-grid article > button { grid-column:auto; grid-row:auto; }
|
||||||
|
.snapshot-ledger-grid dl { grid-template-columns:repeat(2,1fr); }
|
||||||
|
.ledger-toolbar-actions { width:100%; margin-left:0; }
|
||||||
|
.ledger-toolbar-actions button { flex:1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { admissionCutoffRows, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs';
|
import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus, supplementarySchoolIds } from '../src/services/volunteer-admission.mjs';
|
||||||
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
|
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
|
||||||
|
import { systemNotificationItems } from '../src/services/system-notifications.mjs';
|
||||||
|
|
||||||
const now = '2026-07-21T08:00:00.000Z';
|
const now = '2026-07-21T08:00:00.000Z';
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -46,28 +47,90 @@ const db = {
|
|||||||
const setting = { examId: 'exam', payload: { round: 1 } };
|
const setting = { examId: 'exam', payload: { round: 1 } };
|
||||||
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
|
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
|
||||||
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
|
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
|
||||||
|
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { code: 'general' }), 215, '普通招生类别不得加入特征分');
|
||||||
|
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { specialtyCategory: 'sports', specialtyType: 'track_field' }), 303.5, '特长生招生类别应使用文化课总分加特征分');
|
||||||
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
|
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送,但不并入文化课总分');
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送');
|
||||||
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.culturalScore, 215, '特长生投档材料应保留文化课原始总分');
|
||||||
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.totalScore, 303.5, '特长生类别投档总分应加入特征分');
|
||||||
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
|
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
|
||||||
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
|
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
|
||||||
|
|
||||||
|
const specialtyPriorityDb = structuredClone(db);
|
||||||
|
specialtyPriorityDb.users.push({ id: 'u-sport-rival', role: 'candidate', active: true, candidateNumber: '20260004', displayName: '特长竞争考生' });
|
||||||
|
specialtyPriorityDb.candidateProfiles.push({ userId: 'u-sport-rival', name: '特长竞争考生', schoolId: 'source-b', profileCompleted: true, specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] });
|
||||||
|
specialtyPriorityDb.registrations.push({ id: 'r-sport-rival', examId: 'exam', userId: 'u-sport-rival', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 0 });
|
||||||
|
specialtyPriorityDb.results.push({ registrationId: 'r-sport-rival', subjectId: 'cn', score: 125, published: true }, { registrationId: 'r-sport-rival', subjectId: 'math', score: 125, published: true });
|
||||||
|
specialtyPriorityDb.admissionRecords.push(
|
||||||
|
{ id: 'qual-sport-rival', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport-rival', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
|
||||||
|
{ id: 'pref-sport-rival', kind: 'preference', examId: 'exam', userId: 'u-sport-rival', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
|
||||||
|
);
|
||||||
|
const specialtyPriorityPlacements = buildVolunteerPlacements(specialtyPriorityDb, setting, { uid: prefix => `${prefix}-priority-${++sequence}`, nowIso: () => now });
|
||||||
|
assert.equal(specialtyPriorityPlacements.find(item => item.payload.categoryCode === 'sport').userId, 'u-sport', '特长类别应按文化课加特征分排序,而不是只按文化课排序');
|
||||||
|
|
||||||
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
||||||
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
||||||
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
|
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
|
||||||
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
|
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
|
||||||
|
|
||||||
|
const supplementDb = structuredClone(db);
|
||||||
|
const supplementSetting = { id: 'setting-supplement', kind: 'setting', examId: 'exam', status: 'supplementary', payload: { round: 2 } };
|
||||||
|
supplementDb.admissionRecords.push(
|
||||||
|
supplementSetting,
|
||||||
|
{ id: 'report-a', kind: 'notification', examId: 'exam', schoolId: 'target-a', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } },
|
||||||
|
{ id: 'report-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } }
|
||||||
|
);
|
||||||
|
for (const placement of supplementDb.admissionRecords.filter(item => item.kind === 'placement' && item.payload?.categoryCode === 'general')) placement.status = 'forfeited';
|
||||||
|
for (const [suffix, schoolId, score] of [['a', 'target-a', 230], ['b', 'target-b', 225]]) {
|
||||||
|
supplementDb.users.push({ id: `u-supp-${suffix}`, role: 'candidate', active: true, candidateNumber: `2026010${suffix === 'a' ? 1 : 2}`, displayName: `补录考生${suffix.toUpperCase()}` });
|
||||||
|
supplementDb.candidateProfiles.push({ userId: `u-supp-${suffix}`, name: `补录考生${suffix.toUpperCase()}`, schoolId: 'source-a', profileCompleted: true, specialtyTypes: [] });
|
||||||
|
supplementDb.registrations.push({ id: `r-supp-${suffix}`, examId: 'exam', userId: `u-supp-${suffix}`, status: 'approved', subjectIds: ['cn'] });
|
||||||
|
supplementDb.results.push({ registrationId: `r-supp-${suffix}`, subjectId: 'cn', score, published: true });
|
||||||
|
supplementDb.admissionRecords.push({ id: `pref-supp-${suffix}`, kind: 'preference', examId: 'exam', userId: `u-supp-${suffix}`, status: 'submitted', payload: { round: 2, choices: [{ schoolId, categoryCode: 'general', preferenceType: 'general' }] } });
|
||||||
|
}
|
||||||
|
assert.deepEqual([...supplementarySchoolIds(supplementDb, supplementSetting)].sort(), ['target-a', 'target-b'], '同轮多所学校获批补录时应完整保留学校集合');
|
||||||
|
const supplementPlacements = buildVolunteerPlacements(supplementDb, supplementSetting, { uid: prefix => `${prefix}-supp-${++sequence}`, nowIso: () => now });
|
||||||
|
assert.deepEqual(supplementPlacements.map(item => item.schoolId).sort(), ['target-a', 'target-b'], '放弃考生不得继续占用缺额,两所获批学校都应进入补录投档');
|
||||||
const publicRows = publicAdmissionRows(db, 'exam');
|
const publicRows = publicAdmissionRows(db, 'exam');
|
||||||
assert.equal(publicRows[0].registrationNumber, '20260001', '公示必须公开报名号');
|
const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
|
||||||
assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名');
|
assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
|
||||||
assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩');
|
assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
|
||||||
assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校');
|
assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
|
||||||
assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
|
assert.equal(highPublicRow.admittedSchool, '第二中学', '公示必须公开录取学校');
|
||||||
|
assert.ok(highPublicRow.idNumberMasked.includes('*') && !highPublicRow.idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
|
||||||
|
assert.equal(publicRows.find(item => item.registrationNumber === '20260003').totalScore, 303.5, '特长生类别公示总成绩应包含特征分');
|
||||||
const cutoffs = admissionCutoffRows(db, 'exam');
|
const cutoffs = admissionCutoffRows(db, 'exam');
|
||||||
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
|
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
|
||||||
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
|
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
|
||||||
assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
|
assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
|
||||||
assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
|
assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
|
||||||
|
|
||||||
|
const documentDb = structuredClone(db);
|
||||||
|
documentDb.exams = [{ id: 'exam', code: 'EX-2026-ZK', name: '中考' }];
|
||||||
|
documentDb.schools.find(item => item.id === 'target-b').code = 'AD02';
|
||||||
|
const targetPlacements = documentDb.admissionRecords.filter(item => item.kind === 'placement' && item.schoolId === 'target-b');
|
||||||
|
const numbered = assignAdmissionNoticeNumbers(documentDb, targetPlacements);
|
||||||
|
assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号');
|
||||||
|
const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b');
|
||||||
|
documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item);
|
||||||
|
const legacyRoundDb = structuredClone(documentDb);
|
||||||
|
legacyRoundDb.admissionRecords.push({ id: 'setting-exam', kind: 'setting', examId: 'exam', status: 'reporting', updatedAt: now, payload: { enabled: true, autoPublish: true, round: 1, roundPublishedAt: now } });
|
||||||
|
const legacyRoundPublication = admissionRoundPublications(legacyRoundDb).find(item => item.examId === 'exam' && item.round === 1);
|
||||||
|
assert.ok(legacyRoundPublication?.virtual, '历史报到中数据缺少轮次公示记录时应自动兼容回显');
|
||||||
|
assert.equal(legacyRoundPublication.rows.length, publicAdmissionRows(legacyRoundDb, 'exam', { round: 1 }).length, '历史轮次公示应恢复该轮全部正式录取名单');
|
||||||
|
documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } });
|
||||||
|
const progress = admissionPlanProgress(documentDb, reportingPlan);
|
||||||
|
assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数');
|
||||||
|
assert.equal(progress.reportedCount, 1, '实际报到人数应来自学校报到暂存台账');
|
||||||
|
assert.equal(progress.reportingRate, 50, '实际报到完成率应实时按计划人数计算');
|
||||||
|
documentDb.admissionRecords.at(-1).status = 'approved';
|
||||||
|
documentDb.admissionRecords.at(-1).payload.supplementDecision = 'no_supplement';
|
||||||
|
documentDb.admissionRecords.at(-1).payload.decisionNote = '学校研究决定不进行补录。';
|
||||||
|
documentDb.admissionRecords.at(-1).payload.statistics = progress;
|
||||||
|
const reportingNotice = systemNotificationItems(documentDb).find(item => item.sourceType === 'reporting');
|
||||||
|
assert.ok(reportingNotice.title.includes('报到情况公示') && !reportingNotice.title.includes('补录'), '计划完成或决定不补录时公告标题不应出现“补录”');
|
||||||
|
|
||||||
console.log('志愿投档、指标名额与脱敏公示测试通过');
|
console.log('志愿投档、指标名额与脱敏公示测试通过');
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAdminViews } from '../src/client/admin-views.mjs';
|
||||||
|
import { createAdmissionViews } from '../src/client/admission-views.mjs';
|
||||||
|
import { api } from '../src/client/api.mjs';
|
||||||
|
import { createCandidateViews } from '../src/client/candidate-views.mjs';
|
||||||
|
import { createPublicViews } from '../src/client/public-views.mjs';
|
||||||
|
|
||||||
|
function protectedViewContext() {
|
||||||
|
let loginRequests = 0;
|
||||||
|
const context = {
|
||||||
|
state: { user: null },
|
||||||
|
app: { classList: { add() {}, remove() {} } },
|
||||||
|
requireLogin() { loginRequests += 1; }
|
||||||
|
};
|
||||||
|
return { context, loginRequests: () => loginRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
|
||||||
|
const fixture = protectedViewContext();
|
||||||
|
const views = createView(fixture.context);
|
||||||
|
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
|
||||||
|
await render('dashboard');
|
||||||
|
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const app = { classList: { remove() {} }, innerHTML: '' };
|
||||||
|
const state = {
|
||||||
|
user: null,
|
||||||
|
authNotice: '登录状态已失效,请重新登录。',
|
||||||
|
publicData: { selfRegistrationEnabled: false }
|
||||||
|
};
|
||||||
|
const { renderAuth } = createPublicViews({
|
||||||
|
state,
|
||||||
|
app,
|
||||||
|
h: value => String(value ?? ''),
|
||||||
|
icons: { arrow: '', menu: '' }
|
||||||
|
});
|
||||||
|
renderAuth('login');
|
||||||
|
assert.match(app.innerHTML, /需要重新登录/);
|
||||||
|
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('客户端登录失效处理测试通过');
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from '../src/security/document-verification.mjs';
|
||||||
|
|
||||||
|
const secret = 'test-document-verification-secret-32-characters';
|
||||||
|
const exam = { id: 'exam_1' };
|
||||||
|
const registration = { id: 'registration_1', userId: 'candidate_1' };
|
||||||
|
const results = [
|
||||||
|
{ subjectId: 'math', score: 118, publishedAt: '2026-07-20T08:00:00.000Z' },
|
||||||
|
{ subjectId: 'chinese', score: 112, publishedAt: '2026-07-20T08:00:00.000Z' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const scoreCode = scoreReportCode(secret, registration, exam, results);
|
||||||
|
const reorderedCode = scoreReportCode(secret, registration, exam, [...results].reverse());
|
||||||
|
const changedScoreCode = scoreReportCode(secret, registration, exam, [{ ...results[0], score: 119 }, results[1]]);
|
||||||
|
|
||||||
|
assert.match(scoreCode, /^SR-[A-F0-9]{24}$/);
|
||||||
|
assert.equal(scoreCode, reorderedCode, '科目返回顺序不应改变同一成绩单的防伪码');
|
||||||
|
assert.notEqual(scoreCode, changedScoreCode, '成绩变化必须使旧防伪码失效');
|
||||||
|
assert.equal(safeCodeEqual(scoreCode, scoreCode.toLowerCase()), true);
|
||||||
|
assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false);
|
||||||
|
|
||||||
|
const placement = {
|
||||||
|
id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1',
|
||||||
|
payload: { categoryCode: 'general', noticeNumber: 'AD01-EX-2026-000001' }, updatedAt: '2026-07-21T08:00:00.000Z'
|
||||||
|
};
|
||||||
|
const noticeCode = admissionNoticeCode(secret, placement, exam);
|
||||||
|
const movedSchoolCode = admissionNoticeCode(secret, { ...placement, schoolId: 'school_2' }, exam);
|
||||||
|
const changedNumberCode = admissionNoticeCode(secret, { ...placement, payload: { ...placement.payload, noticeNumber: 'AD01-EX-2026-000002' } }, exam);
|
||||||
|
|
||||||
|
assert.match(noticeCode, /^AN-[A-F0-9]{24}$/);
|
||||||
|
assert.notEqual(noticeCode, movedSchoolCode, '录取学校变化必须使旧通知书防伪码失效');
|
||||||
|
assert.notEqual(noticeCode, changedNumberCode, '录取通知书编号变化必须使旧防伪码失效');
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: 'too-short' }),
|
||||||
|
/至少 32 个字符/,
|
||||||
|
'生产环境不得静默使用弱密钥或开发回退值'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: secret }),
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
|
|
||||||
|
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
|
||||||
|
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
|
||||||
|
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
|
||||||
|
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
|
||||||
|
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
|
||||||
|
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
|
||||||
|
const mainPlans = state.admissionRecords.filter(record => record.kind === 'plan' && record.examId === mainExam.id && record.status === 'approved');
|
||||||
|
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
|
||||||
|
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
|
||||||
|
const profilesByUserId = new Map(state.candidateProfiles.map(profile => [profile.userId, profile]));
|
||||||
|
|
||||||
|
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
|
||||||
|
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
|
||||||
|
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
|
||||||
|
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
|
||||||
|
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
|
||||||
|
});
|
||||||
|
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
|
||||||
|
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
|
||||||
|
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
|
||||||
|
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
|
||||||
|
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
|
||||||
|
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
|
||||||
|
assert.equal(mainPlans.length, 3, '三所招生校都应有已审核通过的招生计划');
|
||||||
|
assert.ok(mainPlans.every(plan => plan.payload.categories.find(category => category.code === 'general')?.quota === 350), '每所招生校普通类计划应为 350 人');
|
||||||
|
assert.ok(mainPlans.every(plan => plan.payload.categories.filter(category => category.specialtyCategory).reduce((sum, category) => sum + category.quota, 0) === 2), '每所招生校特长生计划合计应为 2 人');
|
||||||
|
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
|
||||||
|
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
|
||||||
|
assert.ok(mainPreferences.every(preference => {
|
||||||
|
const profile = profilesByUserId.get(preference.userId);
|
||||||
|
const categories = preference.payload.choices.map(choice => choice.categoryCode);
|
||||||
|
return profile.specialtyCategory ? categories[0] === profile.specialtyCategory && categories.slice(1).every(code => code === 'general') : categories.every(code => code === 'general');
|
||||||
|
}), '特长生第一志愿应匹配本人特长类别,其余志愿及普通考生志愿应填报普通类');
|
||||||
|
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
|
||||||
|
|
||||||
|
for (const subject of mainExam.subjects) {
|
||||||
|
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
|
||||||
|
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
||||||
|
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
|
||||||
|
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
|
||||||
|
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
|
||||||
|
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
|
||||||
+221
-12
@@ -9,8 +9,10 @@ import { createDatabase, relationalTables } from '../database.mjs';
|
|||||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
import { mysqlSchema } from '../src/database/schema.mjs';
|
import { mysqlSchema } from '../src/database/schema.mjs';
|
||||||
|
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||||
import { totpAtStep } from '../src/security/totp.mjs';
|
import { totpAtStep } from '../src/security/totp.mjs';
|
||||||
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
||||||
|
import { admissionNoticeCode } from '../src/security/document-verification.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
|
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
|
||||||
@@ -60,7 +62,12 @@ assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例
|
|||||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||||
|
assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本');
|
||||||
|
const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8');
|
||||||
|
assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程');
|
||||||
|
assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite');
|
||||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||||
|
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
|
||||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||||
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
||||||
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
|
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
|
||||||
@@ -83,7 +90,8 @@ process.env.SQLITE_PATH = testDb;
|
|||||||
const seededTestDatabase = await createDatabase({
|
const seededTestDatabase = await createDatabase({
|
||||||
root,
|
root,
|
||||||
seed: () => {
|
seed: () => {
|
||||||
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
// 全量 1200 人规模由 seed.test.mjs 单独验证;端到端流程使用较小样本控制运行时间。
|
||||||
|
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword, candidateCount: 360 });
|
||||||
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
|
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
|
||||||
state.users.push({
|
state.users.push({
|
||||||
...currentSuperAdmin,
|
...currentSuperAdmin,
|
||||||
@@ -156,6 +164,7 @@ try {
|
|||||||
const clientSources = await Promise.all([
|
const clientSources = await Promise.all([
|
||||||
'app.js',
|
'app.js',
|
||||||
'src/client/admin-views.mjs',
|
'src/client/admin-views.mjs',
|
||||||
|
'src/client/admission-views.mjs',
|
||||||
'src/client/candidate-views.mjs',
|
'src/client/candidate-views.mjs',
|
||||||
'src/client/public-views.mjs'
|
'src/client/public-views.mjs'
|
||||||
].map(file => readFile(resolve(root, file), 'utf8')));
|
].map(file => readFile(resolve(root, file), 'utf8')));
|
||||||
@@ -170,6 +179,9 @@ try {
|
|||||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||||
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
||||||
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
|
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
|
||||||
|
assert.match(appSource, /data-action="bulk-placement-review"/, '招生学校投档审核应提供批量处理入口');
|
||||||
|
assert.match(appSource, /data-action="toggle-admission-account"/, '超级管理员应有招生学校账户启停入口');
|
||||||
|
assert.match(appSource, /data-action="reset-admission-account-password"/, '超级管理员应有招生学校账户密码重置入口');
|
||||||
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
||||||
|
|
||||||
const { DatabaseSync } = await import('node:sqlite');
|
const { DatabaseSync } = await import('node:sqlite');
|
||||||
@@ -587,7 +599,8 @@ try {
|
|||||||
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
|
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
|
||||||
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
|
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
|
||||||
const exam = createExam.data.exam;
|
const exam = createExam.data.exam;
|
||||||
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能按考试启用志愿功能并设置填报次数');
|
const admissionSettingResult = await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } });
|
||||||
|
assert.equal(admissionSettingResult.response.status, 200, `超级管理员应能按考试启用志愿功能并设置填报次数:${JSON.stringify(admissionSettingResult.data)}\n${serverError}`);
|
||||||
const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [
|
const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [
|
||||||
{ code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] },
|
{ code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] },
|
||||||
{ code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] }
|
{ code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] }
|
||||||
@@ -595,6 +608,20 @@ try {
|
|||||||
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
|
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
|
||||||
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
|
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
|
||||||
assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
|
assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
|
||||||
|
const publicPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||||
|
const publicPlan = publicPlanAnnouncements.data.plans.find(item => item.id === structuredPlan.data.plan.id);
|
||||||
|
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
|
||||||
|
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
|
||||||
|
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
|
||||||
|
const noticeControlList = await admin.request('/api/admin/notices');
|
||||||
|
const controlledPlan = noticeControlList.data.publications.find(item => item.sourceType === 'plan' && item.id === structuredPlan.data.plan.id);
|
||||||
|
assert.ok(controlledPlan?.visible, '自动发布的招生计划应出现在通知发布管理页并默认显示');
|
||||||
|
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: false } })).response.status, 200, '通知发布管理页应能隐藏系统招生计划公示');
|
||||||
|
const hiddenPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||||
|
assert.equal(hiddenPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), false, '隐藏系统公示后公开通知目录不得继续返回该招生计划');
|
||||||
|
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: true } })).response.status, 200, '已隐藏的系统招生计划公示应能重新显示');
|
||||||
|
const restoredPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||||
|
assert.ok(restoredPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), '重新显示后招生计划应恢复到公开通知目录');
|
||||||
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
|
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
|
||||||
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
|
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
|
||||||
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
|
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
|
||||||
@@ -615,6 +642,132 @@ try {
|
|||||||
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
|
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
|
||||||
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
|
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
|
||||||
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
|
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
|
||||||
|
|
||||||
|
const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3);
|
||||||
|
assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生');
|
||||||
|
const reportedCandidate = createClient();
|
||||||
|
const reportedCandidateLogin = await reportedCandidate.request('/api/auth/login', { method: 'POST', body: { username: placementCandidates[0].registrationNumber, password: '12345678' } });
|
||||||
|
assert.equal(reportedCandidateLogin.response.status, 200, '报到补录边界测试应能登录正式录取考生账号');
|
||||||
|
const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`);
|
||||||
|
const placementCreatedAt = new Date().toISOString();
|
||||||
|
const placementWriter = new DatabaseSync(testDb);
|
||||||
|
const insertPlacement = placementWriter.prepare(`
|
||||||
|
INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at)
|
||||||
|
VALUES (?, 'placement', ?, ?, ?, 'school_review', ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const [index, row] of placementCandidates.entries()) {
|
||||||
|
insertPlacement.run(
|
||||||
|
placementIds[index], exam.id, row.userId, admissionOnlySchool.data.school.id,
|
||||||
|
JSON.stringify({ categoryCode: index === 2 ? 'arts' : 'general', categoryName: index === 2 ? '美术特长生' : '普通生', totalScore: 620 - index, preferenceOrder: index + 1 }),
|
||||||
|
placementCreatedAt, placementCreatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
placementWriter.prepare("UPDATE admission_records SET status = 'school_review' WHERE kind = 'setting' AND exam_id = ?").run(exam.id);
|
||||||
|
placementWriter.close();
|
||||||
|
|
||||||
|
const placementReviewLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||||
|
const seededPlacements = placementReviewLedger.data.placements.filter(item => placementIds.includes(item.id));
|
||||||
|
assert.equal(seededPlacements.length, 3, '招生学校投档台账应返回可供筛选、搜索的完整候选记录');
|
||||||
|
assert.ok(seededPlacements.every(item => item.examName === exam.name && item.candidate.registrationNumber), '投档台账应包含考试名称和考生报名号');
|
||||||
|
const bulkAccept = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept', note: '批量核验通过' } });
|
||||||
|
assert.equal(bulkAccept.response.status, 200, '招生学校应能批量接收多名投档考生');
|
||||||
|
assert.equal(bulkAccept.data.count, 2, '批量接收应返回实际处理人数');
|
||||||
|
const shortBulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '材料不符' } });
|
||||||
|
assert.equal(shortBulkWithdrawal.response.status, 400, '批量退档必须填写充分的特殊理由');
|
||||||
|
const bulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '专项资格证明材料复核不通过' } });
|
||||||
|
assert.equal(bulkWithdrawal.response.status, 200, '招生学校应能批量申请退档');
|
||||||
|
const processedPlacementLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||||
|
assert.ok(processedPlacementLedger.data.placements.filter(item => placementIds.slice(0, 2).includes(item.id)).every(item => item.status === 'admitted'), '批量接收后所选记录应全部进入拟录取状态');
|
||||||
|
assert.equal(processedPlacementLedger.data.placements.find(item => item.id === placementIds[2]).status, 'withdrawal_pending', '批量退档后记录应进入上级审核状态');
|
||||||
|
assert.equal((await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept' } })).response.status, 409, '已处理记录不得被重复批量审核');
|
||||||
|
|
||||||
|
assert.equal((await admin.request(`/api/admin/admission-withdrawals/${placementIds[2]}`, { method: 'PATCH', body: { approved: true, reviewNote: '同意退档' } })).response.status, 200, '超级管理员应先办结退档再签发录取通知书');
|
||||||
|
const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' });
|
||||||
|
assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到');
|
||||||
|
assert.equal(finalizedAdmission.data.admittedCount, 2, '正式签发人数应与学校接收人数一致');
|
||||||
|
const roundAdmissionAnnouncements = await anonymous.request('/api/public/announcements');
|
||||||
|
const roundAdmissionPublication = roundAdmissionAnnouncements.data.admissions.find(item => item.examId === exam.id && item.round === 1);
|
||||||
|
assert.ok(roundAdmissionPublication, '每轮录取通知书签发后应立即自动生成本轮录取名单公示');
|
||||||
|
assert.equal(roundAdmissionPublication.rows.length, 2, '本轮录取公示应固定保存本轮全部正式录取考生');
|
||||||
|
assert.ok(roundAdmissionPublication.title.includes('第 1 轮录取名单公示'), '本轮公示标题应明确标注录取轮次');
|
||||||
|
const homeAfterRoundAdmission = await anonymous.request('/api/public/home');
|
||||||
|
assert.ok(homeAfterRoundAdmission.data.notices.some(item => item.title.includes('第 1 轮录取名单公示')), '本轮录取公示应同步进入首页通知公告');
|
||||||
|
const reportingLedger = await admissionSchoolClient.request('/api/admission/reporting');
|
||||||
|
const reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id);
|
||||||
|
assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生');
|
||||||
|
assert.deepEqual(reportingBatch.rows.map(item => item.noticeNumber), [`${admissionOnlySchool.data.school.code}-${exam.code}-000001`, `${admissionOnlySchool.data.school.code}-${exam.code}-000002`], '通知书编号应使用学校代码、考试代码和学校独立流水号');
|
||||||
|
const reportingExport = await admissionSchoolClient.request(`/api/admission/reporting/export?examId=${exam.id}`);
|
||||||
|
assert.equal(reportingExport.response.status, 200, '招生学校应能导出报到状态 Excel');
|
||||||
|
const reportingWorkbook = new ExcelJS.Workbook(); await reportingWorkbook.xlsx.load(reportingExport.data);
|
||||||
|
const reportingSheet = reportingWorkbook.getWorksheet('考生报到');
|
||||||
|
assert.ok(reportingSheet.getRow(2).values.includes('报到状态码*(Y/N/P)'), '报到 Excel 应明确提供 Y/N/P 状态码列');
|
||||||
|
assert.equal(reportingSheet.getCell('G3').value, 'P', '新报到批次导出时应默认为待确认状态码 P');
|
||||||
|
const placementLedgerExport = await admin.request(`/api/admin/admissions/placements/export?examId=${exam.id}&schoolId=${admissionOnlySchool.data.school.id}&status=final&q=${encodeURIComponent(placementCandidates[0].registrationNumber)}`);
|
||||||
|
assert.equal(placementLedgerExport.response.status, 200, '超级管理员应能按考试、招生学校、状态和搜索词导出录取情况台账');
|
||||||
|
const placementLedgerWorkbook = new ExcelJS.Workbook(); await placementLedgerWorkbook.xlsx.load(placementLedgerExport.data);
|
||||||
|
const placementLedgerSheet = placementLedgerWorkbook.getWorksheet('录取情况');
|
||||||
|
assert.ok(placementLedgerSheet.getRow(2).values.includes('录取状态'), '录取情况台账应包含录取状态列');
|
||||||
|
assert.equal(placementLedgerSheet.rowCount, 3, '录取台账导出应严格应用当前筛选条件,只保留命中的一名考生');
|
||||||
|
assert.equal(placementLedgerSheet.getRow(3).values.includes(placementCandidates[0].registrationNumber), true, '筛选后的录取台账应包含搜索命中的报名号');
|
||||||
|
assert.equal((await classAdmin.request(`/api/admin/admissions/placements/export?examId=${exam.id}`)).response.status, 403, '非超级管理员不得导出全市录取台账');
|
||||||
|
|
||||||
|
const finalizedInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||||
|
const finalizedPlacementRow = finalizedInspector.prepare('SELECT * FROM admission_records WHERE id = ?').get(placementIds[0]);
|
||||||
|
finalizedInspector.close();
|
||||||
|
const finalizedPlacement = { id: finalizedPlacementRow.id, kind: finalizedPlacementRow.kind, examId: finalizedPlacementRow.exam_id, userId: finalizedPlacementRow.user_id, schoolId: finalizedPlacementRow.school_id, status: finalizedPlacementRow.status, payload: JSON.parse(finalizedPlacementRow.payload_json), createdAt: finalizedPlacementRow.created_at, updatedAt: finalizedPlacementRow.updated_at };
|
||||||
|
const noticeCode = admissionNoticeCode('development-document-verification-secret', finalizedPlacement, exam);
|
||||||
|
const reportingPreview = await admissionSchoolClient.request('/api/admission/reporting/scan-preview', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}` } });
|
||||||
|
assert.equal(reportingPreview.response.status, 200, '扫描通知书二维码后应先返回考生确认信息');
|
||||||
|
assert.equal(reportingPreview.data.row.status, 'pending', '扫码预览不得提前修改报到状态');
|
||||||
|
const scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}`, status: 'reported' } });
|
||||||
|
assert.equal(scannedReporting.response.status, 200, '确认页点击暂存后应能保存扫码报到结果');
|
||||||
|
assert.equal(scannedReporting.data.row.status, 'reported', '确认页默认选择应能暂存为已报到而不是直接提交');
|
||||||
|
|
||||||
|
const secondRow = reportingBatch.rows.find(row => row.noticeNumber !== finalizedPlacement.payload.noticeNumber);
|
||||||
|
assert.ok(secondRow, '报到测试应找到另一名尚未处理的正式录取考生');
|
||||||
|
const importReportingFile = Buffer.from(await buildWorkbook('admission_reporting', [{ noticeNumber: secondRow.noticeNumber, candidateNumber: secondRow.candidateNumber, name: secondRow.name, examCode: exam.code, schoolCode: admissionOnlySchool.data.school.code, categoryName: secondRow.categoryName, reportingStatusCode: 'N', reportingNote: '逾期未报到' }]));
|
||||||
|
const importedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
|
||||||
|
assert.equal(importedReporting.response.status, 200, '招生学校应能导入修改后的报到 Excel 并暂存');
|
||||||
|
assert.equal(importedReporting.data.count, 1, 'Excel 导入应返回读取行数');
|
||||||
|
assert.equal(importedReporting.data.changedCount, 1, 'Excel 导入应明确返回实际变化人数');
|
||||||
|
assert.equal(importedReporting.data.changes[0].toCode, 'N', 'Excel 导入变化摘要应说明修改后的状态码');
|
||||||
|
const unchangedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
|
||||||
|
assert.equal(unchangedReporting.data.changedCount, 0, '重复导入相同 Excel 时应明确提示没有变化');
|
||||||
|
assert.equal(unchangedReporting.data.unchangedCount, 1, '重复导入相同 Excel 时应返回未变化行数');
|
||||||
|
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/submit', { method: 'POST', body: { examId: exam.id } })).response.status, 200, '全部状态确认后招生学校应能提交报到情况');
|
||||||
|
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/decision', { method: 'POST', body: { examId: exam.id, supplement: true, decisionNote: '一名考生未报到,申请补录缺额' } })).response.status, 200, '招生学校应能根据实时完成率提交补录决定');
|
||||||
|
const reportingApprovalLedger = await admin.request('/api/admin/admissions');
|
||||||
|
const pendingReportingApproval = reportingApprovalLedger.data.reportingRequests.find(item => item.examId === exam.id && item.status === 'pending_approval');
|
||||||
|
assert.ok(pendingReportingApproval, '超级管理员应看到招生学校报到与补录审批待办');
|
||||||
|
const supplementEnd = new Date(Date.now() + 48 * hour).toISOString();
|
||||||
|
const approvedReporting = await admin.request(`/api/admin/admission-reporting/${pendingReportingApproval.id}`, { method: 'PATCH', body: { approved: true, approvalNote: '同意按缺额补录', preferenceEnd: supplementEnd } });
|
||||||
|
assert.equal(approvedReporting.response.status, 200, '超级管理员应能批准补录并自动公开报到情况');
|
||||||
|
assert.equal(approvedReporting.data.phase, 'supplementary', '全部学校审批完成且存在补录申请时应自动开启下一轮补录');
|
||||||
|
const reportedCandidateSupplementView = await reportedCandidate.request('/api/candidate/admissions');
|
||||||
|
const reportedCandidateAdmission = reportedCandidateSupplementView.data.admissions.find(item => item.examId === exam.id);
|
||||||
|
assert.equal(reportedCandidateAdmission.supplementEligible, false, '已经正式录取并报到的考生不得再次进入补录填报');
|
||||||
|
assert.match(reportedCandidateAdmission.supplementIneligibilityReason, /已被录取/, '考生页面应明确说明不能重复参加补录的原因');
|
||||||
|
const repeatedSupplementPreference = await reportedCandidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: admissionOnlySchool.data.school.id, categoryCode: 'general', preferenceType: 'general' }] } });
|
||||||
|
assert.equal(repeatedSupplementPreference.response.status, 403, '服务端必须拒绝已录取且已报到考生再次提交补录志愿');
|
||||||
|
const reportingAnnouncements = await anonymous.request('/api/public/announcements');
|
||||||
|
const reportingPublication = reportingAnnouncements.data.reports.find(item => item.id === pendingReportingApproval.id);
|
||||||
|
assert.ok(reportingPublication?.title.includes('补录说明'), '批准补录后报到公示标题应自动包含补录说明');
|
||||||
|
assert.equal(reportingPublication.statistics.reportedCount, 1, '公开报到情况应包含简单统计数据');
|
||||||
|
const homeAfterReporting = await anonymous.request('/api/public/home');
|
||||||
|
assert.ok(homeAfterReporting.data.notices.some(item => item.id === `system-reporting-${pendingReportingApproval.id}`), '系统自动生成的报到公示应进入所有用户共用的通知列表');
|
||||||
|
|
||||||
|
const admissionAccountLedger = await admin.request('/api/admin/admissions');
|
||||||
|
const managedAdmissionAccount = admissionAccountLedger.data.schoolAccounts.find(item => item.id === admissionAccount.data.account.id);
|
||||||
|
assert.equal(managedAdmissionAccount.schoolName, '海州市招生实验学校', '招生学校账户台账应显示绑定学校');
|
||||||
|
assert.equal(managedAdmissionAccount.active, true, '招生学校账户台账应返回真实启停状态');
|
||||||
|
const disabledAdmissionAccount = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: false } });
|
||||||
|
assert.equal(disabledAdmissionAccount.data.account.active, false, '超级管理员应能停用招生学校账户');
|
||||||
|
assert.equal((await admissionSchoolClient.request('/api/admission/context')).response.status, 401, '停用招生学校账户应立即使既有会话失效');
|
||||||
|
assert.equal((await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: true } })).data.account.active, true, '超级管理员应能重新启用招生学校账户');
|
||||||
|
const resetAdmissionPassword = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}/reset-password`, { method: 'POST' });
|
||||||
|
assert.match(resetAdmissionPassword.data.temporaryPassword, /^Reset-/, '重置招生学校账户密码应返回一次性临时密码');
|
||||||
|
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: 'Admission123!' } })).response.status, 401, '招生学校账户重置后原密码应失效');
|
||||||
|
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: resetAdmissionPassword.data.temporaryPassword } })).response.status, 200, '招生学校账户应能使用临时密码重新登录');
|
||||||
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||||
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
||||||
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
||||||
@@ -689,6 +842,10 @@ try {
|
|||||||
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
||||||
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
||||||
assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段');
|
assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段');
|
||||||
|
assert.deepEqual(adminRegistration.subjects.map(item => item.id), [exam.subjects[0].id, exam.subjects[2].id], '报名审核详情应完整提供考生所报科目');
|
||||||
|
const candidateReviewList = await admin.request('/api/admin/candidates');
|
||||||
|
const candidateReviewItem = candidateReviewList.data.candidates.find(item => item.id === profile.id);
|
||||||
|
assert.ok(candidateReviewItem.registrations.some(item => item.id === registrationId && item.exam?.id === exam.id && item.subjects.length === 2), '考生资料审核应同时提供关联考试和所报科目上下文');
|
||||||
|
|
||||||
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
||||||
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
||||||
@@ -886,31 +1043,62 @@ try {
|
|||||||
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
||||||
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
||||||
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
||||||
|
const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '<p>初始草稿内容</p>', status: 'draft' } });
|
||||||
|
assert.equal(draftNotice.response.status, 201, '管理员应能保存通知草稿');
|
||||||
|
const editedDraftNotice = await admin.request(`/api/admin/notices/${draftNotice.data.notice.id}`, { method: 'PATCH', body: { title: '已编辑草稿', content: '<p>修改后的草稿内容</p>', status: 'draft' } });
|
||||||
|
assert.equal(editedDraftNotice.response.status, 200, '草稿编辑入口应通过原通知修改接口保存');
|
||||||
|
assert.equal(editedDraftNotice.data.notice.title, '已编辑草稿');
|
||||||
|
const noticeListAfterEdit = await admin.request('/api/admin/notices');
|
||||||
|
assert.equal(noticeListAfterEdit.data.notices.filter(item => item.id === draftNotice.data.notice.id).length, 1, '编辑草稿不得重复新建通知');
|
||||||
|
assert.match(noticeListAfterEdit.data.notices.find(item => item.id === draftNotice.data.notice.id).contentHtml, /修改后的草稿内容/, '草稿正文修改后应正确回填到管理接口');
|
||||||
const refreshedHome = await anonymous.request('/api/public/home');
|
const refreshedHome = await anonymous.request('/api/public/home');
|
||||||
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
||||||
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
||||||
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
|
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
|
||||||
|
|
||||||
|
const resultTemplate = await admin.request(`/api/admin/excel/results?template=1&examId=${encodeURIComponent(exam.id)}`);
|
||||||
|
assert.equal(resultTemplate.response.status, 200, '成绩模板应支持按考试下载完整录分名单');
|
||||||
|
const resultTemplateWorkbook = new ExcelJS.Workbook();
|
||||||
|
await resultTemplateWorkbook.xlsx.load(resultTemplate.data);
|
||||||
|
const resultTemplateSheet = resultTemplateWorkbook.getWorksheet('成绩');
|
||||||
|
const templateHeaders = resultTemplateSheet.getRow(2).values;
|
||||||
|
assert.ok(templateHeaders.includes('报名号*') && templateHeaders.includes('准考证号(只读参考)') && templateHeaders.includes('姓名(只读参考)'), '成绩模板应包含报名号、准考证号与姓名列');
|
||||||
|
const templateCandidateNumberColumn = templateHeaders.indexOf('报名号*');
|
||||||
|
const templateCandidateNameColumn = templateHeaders.indexOf('姓名(只读参考)');
|
||||||
|
const templateRows = resultTemplateSheet.getRows(3, Math.max(0, resultTemplateSheet.rowCount - 2)) || [];
|
||||||
|
const candidateTemplateRow = templateRows.find(row => row.getCell(templateCandidateNumberColumn).text === candidateNumber);
|
||||||
|
assert.ok(candidateTemplateRow, '成绩模板应预填已通过报名的真实考生,而不是空白示例行');
|
||||||
|
assert.ok(candidateTemplateRow.getCell(templateCandidateNameColumn).text, '成绩模板应预填考生姓名供核对');
|
||||||
|
|
||||||
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
|
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
|
||||||
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
|
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
|
||||||
assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览');
|
assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览');
|
||||||
assert.equal(stagedPreview.data.preview, true);
|
assert.equal(stagedPreview.data.preview, true);
|
||||||
assert.equal(stagedPreview.data.summary.valid, 1);
|
assert.equal(stagedPreview.data.summary.valid, 1);
|
||||||
assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
const unselectedResults = await admin.request('/api/admin/results');
|
||||||
|
assert.deepEqual(unselectedResults.data.results, [], '未选择考试时不得读取成绩数据');
|
||||||
|
assert.deepEqual(unselectedResults.data.registrations, [], '未选择考试时不得读取录分名单');
|
||||||
|
assert.ok(!(await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
||||||
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
|
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
|
||||||
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
|
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
|
||||||
assert.equal(stagedCommit.data.count, 1);
|
assert.equal(stagedCommit.data.count, 1);
|
||||||
assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
assert.ok((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
||||||
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
|
const bulkDraftResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: false, rows: [{ registrationId, score: 125.5 }] } });
|
||||||
assert.equal(publishResult.response.status, 200);
|
assert.equal(bulkDraftResult.response.status, 200, '名单式录分应支持按科目批量暂存');
|
||||||
|
assert.equal(bulkDraftResult.data.published, false);
|
||||||
|
const publishResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: true, rows: [{ registrationId, score: 126 }] } });
|
||||||
|
assert.equal(publishResult.response.status, 200, '名单式录分应支持按科目批量发布');
|
||||||
|
assert.equal(publishResult.data.published, true);
|
||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
||||||
const adminResults = await admin.request('/api/admin/results');
|
const adminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||||
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
|
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
|
||||||
const featureScoreUpdate = await admin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 87.5 } });
|
const featureScoreUpdate = await admin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 87.5 }] } });
|
||||||
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能登记与科目无关的特征分');
|
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能按考试名单批量登记特征分');
|
||||||
assert.equal(featureScoreUpdate.data.registration.featureScore, 87.5);
|
assert.equal(featureScoreUpdate.data.count, 1);
|
||||||
|
assert.equal((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分');
|
||||||
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
|
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
|
||||||
|
assert.equal((await classAdmin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 10 }] } })).response.status, 403, '班级管理员不得批量登记特征分');
|
||||||
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
||||||
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
||||||
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
||||||
@@ -919,12 +1107,16 @@ try {
|
|||||||
const results = await candidate.request('/api/candidate/results');
|
const results = await candidate.request('/api/candidate/results');
|
||||||
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
||||||
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
||||||
|
assert.match(resultSummary.verificationQr, /^data:image\/png;base64,/, '成绩单防伪信息应包含可直接扫描的二维码');
|
||||||
assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分');
|
assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分');
|
||||||
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
||||||
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
||||||
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
||||||
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
|
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
|
||||||
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
|
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
|
||||||
|
const unfilledPreferenceLedger = await admin.request('/api/admin/admissions');
|
||||||
|
const unfilledPreferenceRow = unfilledPreferenceLedger.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||||
|
assert.equal(unfilledPreferenceRow.status, 'unfilled', '实时志愿台账应包含尚未填报的合格考生,而不只是已提交志愿');
|
||||||
const candidateAdmissions = await candidate.request('/api/candidate/admissions');
|
const candidateAdmissions = await candidate.request('/api/candidate/admissions');
|
||||||
const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
|
const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
|
||||||
assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
|
assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
|
||||||
@@ -935,8 +1127,25 @@ try {
|
|||||||
] } });
|
] } });
|
||||||
assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
|
assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
|
||||||
assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
|
assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
|
||||||
|
const lockedPreferenceView = await candidate.request('/api/candidate/admissions');
|
||||||
|
const lockedChoice = lockedPreferenceView.data.admissions.find(item => item.examId === exam.id).preference.payload.choices[0];
|
||||||
|
assert.equal(lockedChoice.schoolName, '海州市招生实验学校', '考生志愿锁定后应稳定返回学校名称,而不是只返回代码');
|
||||||
|
assert.equal(lockedChoice.categoryName, '普通生', '考生志愿锁定后应稳定返回招生类别名称');
|
||||||
|
const adminPreferenceView = await admin.request('/api/admin/admissions');
|
||||||
|
const adminLockedChoice = adminPreferenceView.data.preferences.find(item => item.id === firstPreference.data.preference.id).choices[0];
|
||||||
|
assert.equal(adminLockedChoice.schoolName, '海州市招生实验学校', '管理员志愿台账应显示完整学校名称');
|
||||||
|
assert.equal(adminLockedChoice.categoryName, '普通生', '管理员志愿台账应显示招生类别名称而不是类别代码');
|
||||||
|
const lockedSnapshot = adminPreferenceView.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||||
|
assert.equal(lockedSnapshot.status, 'locked', '考生提交达到次数上限后,管理员实时台账应同步显示已锁定');
|
||||||
|
const preferenceLedgerExport = await admin.request(`/api/admin/admissions/preferences/export?examId=${exam.id}&round=${lockedSnapshot.round}&status=locked&q=${encodeURIComponent(candidateNumber)}`);
|
||||||
|
assert.equal(preferenceLedgerExport.response.status, 200, '超级管理员应能在填报期间按当前搜索和筛选条件导出志愿台账');
|
||||||
|
const preferenceLedgerWorkbook = new ExcelJS.Workbook(); await preferenceLedgerWorkbook.xlsx.load(preferenceLedgerExport.data);
|
||||||
|
const preferenceLedgerSheet = preferenceLedgerWorkbook.getWorksheet('志愿填报情况');
|
||||||
|
assert.ok(preferenceLedgerSheet.getRow(2).values.includes('填报状态'), '志愿台账应包含填报状态列');
|
||||||
|
assert.equal(preferenceLedgerSheet.rowCount, 4, '一名考生的两个志愿应导出为两条明细,并保留两行表头');
|
||||||
|
assert.equal(preferenceLedgerSheet.getRow(3).values.includes('海州市招生实验学校'), true, '志愿台账应导出学校名称而不是只导出代码');
|
||||||
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
|
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
|
||||||
const classResults = await classAdmin.request('/api/admin/results');
|
const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||||
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
||||||
|
|
||||||
@@ -1017,7 +1226,7 @@ try {
|
|||||||
assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交');
|
assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交');
|
||||||
assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩');
|
assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩');
|
||||||
assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议');
|
assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议');
|
||||||
const archivedAdminResults = await admin.request('/api/admin/results');
|
const archivedAdminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||||
assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态');
|
assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态');
|
||||||
const archivedCandidateResults = await candidate.request('/api/candidate/results');
|
const archivedCandidateResults = await candidate.request('/api/candidate/results');
|
||||||
assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');
|
assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');
|
||||||
|
|||||||
Reference in New Issue
Block a user