From 3e1bced9903bf8098032a2d2490e3dd84271d19c Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 09:50:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E7=AB=99=E5=A4=A7=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E8=A1=A5=E9=BD=90=E8=B7=A8=E9=A1=B5=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E3=80=81=E7=AD=9B=E9=80=89=E5=92=8C=E5=88=86=E9=A1=B5?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=8A=95=E6=A1=A3=E7=9B=91=E7=9D=A3?= =?UTF-8?q?=E3=80=81=E6=8A=A5=E5=88=B0=E5=AE=A1=E6=89=B9=E3=80=81=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E5=A4=8D=E8=AE=AE=E3=80=81=E5=85=AC=E5=BC=80=E5=BD=95?= =?UTF-8?q?=E5=8F=96=E5=90=8D=E5=8D=95=E7=AD=89=E3=80=82=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E9=87=8D=E6=9E=84=E4=B8=BA=E4=B8=80=E7=BA=A7=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=9F=9F=EF=BC=8C=E5=B9=B6=E5=B0=86=E6=8B=9B=E7=94=9F?= =?UTF-8?q?=E5=BD=95=E5=8F=96=E6=8B=86=E4=B8=BA=205=20=E4=B8=AA=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E8=8F=9C=E5=8D=95=E3=80=82=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E7=AD=89=E7=BA=A7=E6=8E=92=E5=90=8D=E9=81=AE?= =?UTF-8?q?=E6=8C=A1=E7=A7=91=E7=9B=AE=E3=80=82=20=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E5=8D=95=E3=80=81=E5=BD=95=E5=8F=96=E9=80=9A=E7=9F=A5=E4=B9=A6?= =?UTF-8?q?=E5=9D=87=E6=94=AF=E6=8C=81=E5=B8=A6=E9=98=B2=E4=BC=AA=E7=A0=81?= =?UTF-8?q?=E5=92=8C=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=9A=84=20PDF=20=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E3=80=82=20=E5=BD=95=E5=8F=96=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E4=B9=A6=E7=A7=BB=E9=99=A4=E2=80=9C=E5=BD=95=E5=8F=96=E4=B8=93?= =?UTF-8?q?=E7=94=A8=E7=AB=A0=E2=80=9D=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AD=A6?= =?UTF-8?q?=E6=A0=A1=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1=E6=9D=BF=E3=80=82?= =?UTF-8?q?=20=E9=80=9A=E7=9F=A5=E4=B9=A6=E7=BC=96=E5=8F=B7=E9=87=87?= =?UTF-8?q?=E7=94=A8=E2=80=9C=E5=AD=A6=E6=A0=A1=E4=BB=A3=E7=A0=81=20+=20?= =?UTF-8?q?=E8=80=83=E8=AF=95=E4=BB=A3=E7=A0=81=20+=20=E5=AD=A6=E6=A0=A1?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=B5=81=E6=B0=B4=E5=8F=B7=E2=80=9D=E3=80=82?= =?UTF-8?q?=20=E5=AE=8C=E6=88=90=E6=8B=9B=E7=94=9F=E5=AD=A6=E6=A0=A1?= =?UTF-8?q?=E6=8A=A5=E5=88=B0=E5=8F=B0=E8=B4=A6=EF=BC=9AY/N/P=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E3=80=81=E6=9A=82=E5=AD=98=E3=80=81Excel=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=AF=BC=E5=87=BA=E3=80=81=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E7=A1=AE=E8=AE=A4=E3=80=82=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E6=8A=A5=E5=88=B0=E6=8F=90=E4=BA=A4=E3=80=81=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=AE=8C=E6=88=90=E7=8E=87=E3=80=81=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=E5=86=B3=E5=AE=9A=E3=80=81=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=AE=A1=E6=89=B9=E5=92=8C=E8=87=AA=E5=8A=A8=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E9=97=AD=E7=8E=AF=E3=80=82=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E7=8E=B0=E5=B7=B2=E8=BF=9B=E5=85=A5=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E9=A6=96=E9=A1=B5=E3=80=81=E8=80=83=E7=94=9F=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8F=8A=E6=8B=9B=E7=94=9F=E5=AD=A6=E6=A0=A1=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E3=80=82=20=E9=AA=8C=E8=AF=81=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=EF=BC=9A=20=E5=AE=8C=E6=95=B4=20npm=20test=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87=E3=80=82=20=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E7=AB=AF=E4=B8=89=E4=B8=AA=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=82=20Excel=20=E6=A8=A1=E6=9D=BF=E5=B7=B2=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=AF=BC=E5=87=BA=E5=B9=B6=E6=B8=B2=E6=9F=93=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E3=80=82=20=E4=B8=A4=E7=B1=BB=20PDF=20=E5=B7=B2?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E7=94=9F=E6=88=90=E3=80=81=E8=BD=AC=E5=9B=BE?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=8C=E4=BA=8C=E7=BB=B4=E7=A0=81=E3=80=81?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E5=92=8C=E6=97=A0=E5=8D=B0=E7=AB=A0=E7=89=88?= =?UTF-8?q?=E5=BC=8F=E6=AD=A3=E5=B8=B8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- app.js | 79 +++++++++++- excel.mjs | 21 +++- index.html | 4 +- server.mjs | 4 + src/client/admin-views.mjs | 53 ++++++--- src/client/admission-views.mjs | 30 +++-- src/client/candidate-views.mjs | 14 +-- src/client/pdf-export.mjs | 30 ++++- src/client/public-views.mjs | 54 +++++++-- src/client/ui.mjs | 2 +- src/routes/admin.routes.mjs | 106 ++++++++++++++--- src/routes/admission.routes.mjs | 159 ++++++++++++++++++++++++- src/routes/candidate.routes.mjs | 39 ++++-- src/routes/public.routes.mjs | 14 ++- src/security/document-verification.mjs | 2 +- src/services/system-notifications.mjs | 59 +++++++++ src/services/volunteer-admission.mjs | 92 +++++++++++++- styles.css | 58 ++++++++- tests/admission.test.mjs | 23 +++- tests/document-verification.test.mjs | 4 +- tests/system.test.mjs | 51 +++++++- 22 files changed, 813 insertions(+), 94 deletions(-) create mode 100644 src/services/system-notifications.mjs diff --git a/README.md b/README.md index 1a9e8c2..0614054 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ - 查看报名审核、应缴金额、缴费状态及班级负责人确认记录 - 准考证生成状态、开放时间与下载 - 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 +- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单 +- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书 - 通知公告中心 ### 管理后台 @@ -269,11 +271,14 @@ npm run seed-test-data:mysql -- --force 4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。 5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。 6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 -7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。 +7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。 +8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。 +9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。 +10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。 公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。 -学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。 +学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。 数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 diff --git a/app.js b/app.js index f832363..ddebbb3 100644 --- a/app.js +++ b/app.js @@ -345,13 +345,13 @@ document.addEventListener('click', async event => { 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('成绩单暂不可下载', '请刷新页面后重试'); - 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, verificationUrl: `${location.origin}/#verify/${summary.verificationCode}` }); + 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('录取通知书暂不可下载', '只有正式录取后才能生成'); - 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, verificationUrl: `${location.origin}/#verify/${item.noticeVerificationCode}` }); + 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') { @@ -405,6 +405,30 @@ document.addEventListener('click', async event => { updateQualificationSelection(container); return; } + if (action === 'download-admission-reporting') { + window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`; + 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 === 'clear-table-filters') { const tableId = target.dataset.target; state.tableFilters[tableId] = { query: '', status: 'all', filters: {} }; @@ -1014,6 +1038,24 @@ document.addEventListener('submit', async event => { } 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') { + const body = formObject(form); + const result = await api('/api/admission/reporting/scan', { method: 'POST', body }); + toast('防伪二维码核验通过', `${result.row.name}已暂存为“已报到”`); 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') { const body = formObject(form); body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => { @@ -1422,6 +1464,39 @@ window.addEventListener('keydown', event => { form.requestSubmit(form.querySelector('[data-result-mode="draft"]')); } } + 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() }); + toast('Excel 已导入并暂存', `已更新 ${result.count} 名考生,提交前仍可修改`); + 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('图片中没有识别到二维码'); + const result = await api('/api/admission/reporting/scan', { method: 'POST', body: { code } }); + toast('二维码核验通过', `${result.row.name}已暂存为“已报到”`); + renderRoute(); + } catch (error) { toast('二维码识别失败', error.message); } + })(); + } }); try { diff --git a/excel.mjs b/excel.mjs index 585fcf2..1d88396 100644 --- a/excel.mjs +++ b/excel.mjs @@ -124,6 +124,17 @@ const resourceSpecs = { ], numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'], numberFormats: { 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'] } } }; @@ -200,7 +211,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub header.alignment = { vertical: 'middle', horizontal: 'center' }; const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : []; 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.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } }; row.alignment = { vertical: 'middle' }; @@ -208,6 +219,14 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub 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 } }; for (const [key, values] of Object.entries(spec.validations || {})) { const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1; diff --git a/index.html b/index.html index a325095..88ddd8f 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ 衡准 · 考试信息管理系统 - +
@@ -21,6 +21,6 @@
操作成功更改已保存
- + diff --git a/server.mjs b/server.mjs index 7239ea4..2ea1b7b 100644 --- a/server.mjs +++ b/server.mjs @@ -18,6 +18,7 @@ import { createBaseDatabase } from './src/data/base.mjs'; import { resolveRegion } from './src/data/region-service.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 envPath = join(root, '.env'); @@ -116,6 +117,9 @@ const database = withCacheInvalidation(persistentDatabase, cache, (method, args) return namespaces; }); 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 requirePermission = createPermissionGuard(sendError); diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index ff80c47..c303875 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -33,6 +33,7 @@ export function createAdminViews(context) { async function renderAdmin(page) { if (state.user?.role !== 'admin') return requireLogin(); + if (page === 'admissions') page = 'admission-settings'; app.classList.add('admin-readable'); const meta = { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], @@ -48,7 +49,11 @@ export function createAdminViews(context) { flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'], - admissions: ['招生录取', '设置志愿窗口、审核招生计划,并按分数优先、遵循志愿执行投档、退档审核和补录。'], + 'admission-settings': ['录取设置', '设置志愿窗口并控制投档、签发通知书和报到阶段。'], + 'admission-accounts': ['招生学校账户', '创建、停用和维护招生学校工作台账户。'], + 'admission-plans': ['招生计划', '代上传、审核招生计划并实时查看计划完成率。'], + 'admission-reporting': ['报到与补录', '审核学校报到统计和是否开展补充录取的决定。'], + 'admission-supervision': ['投档与退档监督', '搜索、筛选投档记录并审批特殊退档;考生志愿保持只读。'], 'indicator-qualifications': ['指标分配资格确认', '由生源校逐人确认;本校全部考生确认后,系统自动发布资格公示。'], security: ['账户安全', '使用当前密码设置新的登录密码。'] }; @@ -56,7 +61,7 @@ export function createAdminViews(context) { if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); try { - const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page; + const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page.startsWith('admission-') ? 'admissions' : page; const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : ''; const data = page === 'results' && !state.resultExamFilter ? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } } @@ -68,10 +73,10 @@ export function createAdminViews(context) { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices, data.publications), admit: () => adminAdmit(data), results: () => adminResults(data), schools: () => adminSchoolsV2(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), - 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data) + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), 'admission-settings': () => adminAdmissionsV2(data, 'settings'), 'admission-accounts': () => adminAdmissionsV2(data, 'accounts'), 'admission-plans': () => adminAdmissionsV2(data, 'plans'), 'admission-reporting': () => adminAdmissionsV2(data, 'reporting'), 'admission-supervision': () => adminAdmissionsV2(data, 'supervision'), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); - if (page === 'admissions') mountPreferenceLedger(data); + if (page === 'admission-supervision') mountPreferenceLedger(data); } catch (error) { renderError(error); } } @@ -142,7 +147,7 @@ export function createAdminViews(context) { .sort((left, right) => left.localeCompare(right, 'zh-CN')) .map(value => ``).join(''); const filters = `
`; - return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}
${bulkToolbar}
${filters}
${rows || ''}
报名号 / 考生证件号码学校 / 年级 / 班级关联考试 / 科目账户状态资料状态操作
当前范围暂无考生
${pagination(candidatePage)}
`; + return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}
${bulkToolbar}
${filters}
${rows || ''}
报名号 / 考生证件号码学校 / 年级 / 班级关联考试 / 科目账户状态资料状态操作
当前范围暂无考生
${pagination(candidatePage)}
`; } function paged(items, key, defaultPageSize = 50) { @@ -184,7 +189,7 @@ export function createAdminViews(context) { const archived = archivedPage.items; const subjectOptions = [...new Set(registrations.flatMap(item => item.subjects.map(subject => subject.name)))].sort((left, right) => left.localeCompare(right, 'zh-CN')).map(value => ``).join(''); const bulkToolbar = canReview ? `
已选择 0 条当前可处理报名
` : ''; - return `
${bulkToolbar}
${table(current, 'registrationTable', Boolean(canReview))}${pagination(currentPage)}
${allArchived.length ? `
归档考试报名记录${allArchived.length} 条 · 流程与报名信息已冻结${allArchived.length}${table(archived, 'archivedRegistrationTable')}${pagination(archivedPage)}
` : ''}`; + return `
${bulkToolbar}
${table(current, 'registrationTable', Boolean(canReview))}${pagination(currentPage)}
${allArchived.length ? `
归档考试报名记录${allArchived.length} 条 · 流程与报名信息已冻结${allArchived.length}
${table(archived, 'archivedRegistrationTable')}${pagination(archivedPage)}
` : ''}`; } function adminPayments(data) { @@ -201,7 +206,7 @@ export function createAdminViews(context) { const rows = registrations.map(item => `${data.canUpdatePayment ? `` : ''}
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h([item.gradeName, item.className].filter(Boolean).join(' · '))}${h(item.exam?.name || '')}
${item.subjects.map(subject => `${h(subject.name)}`).join('')}
${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '管理员')}` : ''}${data.canUpdatePayment && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); const filters = `
`; const bulkToolbar = data.canUpdatePayment ? `
已选择 0 条缴费记录
` : ''; - return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${allRegistrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${bulkToolbar}
${filters}
${data.canUpdatePayment ? '' : ''}${rows || ``}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
${pagination(paymentPage)}
`; + return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${allRegistrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${bulkToolbar}
${filters}
${data.canUpdatePayment ? '' : ''}${rows || ``}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
${pagination(paymentPage)}
`; } function adminExams(exams) { @@ -221,21 +226,31 @@ export function createAdminViews(context) { return `
ADMISSION COMMAND

中考招生录取控制台

志愿内容仅超级管理员可见且不可代改;投档和录取状态变更均进入审计日志。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}

考试志愿设置

不是所有考试都需要开启。

${selected ? `
` : ''}

代上传招生计划

每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。指标分配可由招生学校提交后审核。

招生计划审核

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成提交人状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${category.specialtyType ? ` · ${h(category.specialtyType)}` : ''}`).join('
')}
${h(plan.payload.submittedBy || '超级管理员')}${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划

投档与退档监督

超级管理员可见,任何管理员均不能修改考生志愿
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.payload.totalScore)} 分 · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; } - function adminAdmissionsV2(data) { + function adminAdmissionsV2(data, section = 'settings') { const selected = data.settings.find(item => item.status !== 'completed') || data.settings[0]; const pendingPlans = data.plans.filter(item => item.status === 'pending'); const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); - const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }; + const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取完成' }; const accountPage = paged(data.schoolAccounts, 'admissionAccountTable', 20); const planPage = paged(data.plans, 'adminAdmissionPlanTable', 20); const placementPage = paged(data.placements, 'adminPlacementTable'); - const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
` : ''}
`; + const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
补录不能手工开启,必须由招生学校提交报到情况和补录决定,并经超级管理员审批。` : ''}
`; const accountRows = accountPage.items.map(account => `
${h((account.displayName || '招').slice(0, 1))}
${h(account.displayName)}${h(account.id)}
${h(account.username)}${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')}${account.active ? '已启用' : '已停用'}${formatDate(account.createdAt, true)}
`).join(''); - const accounts = `

招生学校账户管理

创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。

${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
${pagination(accountPage)}
`; + const accounts = `

招生学校账户管理

创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。

${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
${pagination(accountPage)}
`; const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; - const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${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('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; - const placements = `

投档与退档监督

支持跨页搜索考生、报名号、学校与类别;志愿保持只读。

共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${pagination(placementPage)}
`; - return `
ADMISSION COMMAND

中考招生录取控制台

学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
${settings}
${accounts}${planForm}${plans}${placements}`; + const plans = `

招生计划审核与完成率

实时核对计划、正式录取和实际报到完成情况。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成实时完成率指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${h(plan.progress?.admissionRate || 0)}%正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}实际报到 ${h(plan.progress?.reportingRate || 0)}%${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('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; + const reportingPage = paged(data.reportingRequests || [], 'adminReportingApprovalTable', 20); + const reportingApprovals = `

报到情况与补录审批

学校决定审批通过后,统计数据和说明将自动进入公开通知。

${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length} 项待审批
${reportingPage.items.map(item => ``).join('') || ''}
考试 / 学校报到统计学校决定状态审批
${h(item.examName)}${h(item.schoolName)} · 第 ${h(item.payload?.round || 1)} 轮计划 ${h(item.progress.totalQuota)} 人 · 已报到 ${h(item.progress.reportedCount)} 人完成率 ${h(item.progress.reportingRate)}% · 缺额 ${h(item.progress.reportingGap)} 人${item.payload?.supplementDecision === 'supplement' ? '申请补录' : '不进行补录'}${h(item.payload?.decisionNote || '')}${badge(item.status)}${item.status === 'pending_approval' ? `` : h(item.payload?.approvalNote || '—')}
暂无报到与补录审批记录
${pagination(reportingPage)}
`; + const placements = `

投档与退档监督

支持跨页搜索考生、报名号、学校与类别;志愿保持只读。

共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${pagination(placementPage)}
`; + const banner = `
ADMISSION COMMAND

中考招生录取控制台

计划完成率、考生报到与补录审批在同一条可审计链路中实时更新。

待审计划
${pendingPlans.length}
补录待审批
${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
已报到
${(data.reportingRequests || []).reduce((sum, item) => sum + Number(item.progress?.reportedCount || 0), 0)}
`; + const sections = { + settings: `
${settings}
`, + accounts, + plans: `${planForm}${plans}`, + reporting: reportingApprovals, + supervision: placements + }; + return `${banner}${sections[section] || sections.settings}`; } function adminIndicatorQualifications(data) { @@ -285,7 +300,7 @@ export function createAdminViews(context) { }).join(''); return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}
${subjects}
${reg.admitCard ? `` : '—'}`; }).join(''); - return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
${pagination(admitPage)}
`; + return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
${pagination(admitPage)}
`; } function adminResults(data) { @@ -302,6 +317,7 @@ export function createAdminViews(context) { const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; const resultPage = paged(examResults, 'resultTable'); const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); + const appealPage = paged(examAppeals, 'resultAppealTable'); const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id)); const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable'); @@ -323,8 +339,9 @@ export function createAdminViews(context) { return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${h(registration.specialtyLabel || '普通生')}${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}`; }).join(''); const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
UNIFIED SPECIALTY TEST

按名单登记特征分

所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。

分类计分规则普通类别:文化课总分特长类别:文化课总分 + 特征分
本场考生${featureRegistrations.length}默认 0 分${featureRegistrations.length - featureModified}已修改${featureModified}具有特长资格${featureRegistrations.filter(item => item.specialtyType).length}

特征分按考试、按考生独立保存,不改变文化课成绩。

${featureRows || ''}
序号考生报名号准考证号特长资格 / 计分范围特征分(0—1000)
本场暂无已通过报名的考生
尚无未保存修改未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。
` : ''; - const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${preview.rows.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; - const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${examAppeals.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
`; + const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null; + const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${importPreviewPage.items.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}
${pagination(importPreviewPage)}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; + const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${appealPage.items.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
${pagination(appealPage)}
`; const examButton = exam => ``; const currentExams = data.exams.filter(exam => !exam.archivedAt); const archivedExams = data.exams.filter(exam => exam.archivedAt); @@ -334,7 +351,7 @@ export function createAdminViews(context) { : ''; const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '名单模板已预填本场全部考生;导入后先预览,确认才写入数据库'}
${cacheButton}${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; - const ledger = `
${resultPage.items.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
${pagination(resultPage)}
`; + const ledger = `
${resultPage.items.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
${pagination(resultPage)}
`; const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${pagination(resultEntryPage)}${featureEntry}${pagination(featurePage)}${ledger}${appealLedger}`; } diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs index f67fed5..6a7e758 100644 --- a/src/client/admission-views.mjs +++ b/src/client/admission-views.mjs @@ -1,6 +1,6 @@ export function createAdmissionViews(context) { const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context; - const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['notice-template','通知书模板','ticket','文书中心']]; + const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']]; function shell(page, content, title, description) { const groups = [...new Set(nav.map(item => item[3]))]; @@ -10,18 +10,34 @@ export function createAdmissionViews(context) { async function renderAdmission(page) { if (state.user?.role !== 'admission_school') return requireLogin(); if (!nav.some(item => item[0] === page)) page = 'dashboard'; - const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] }; + const meta = { dashboard:['招生工作台','查看本校计划完成率、报到进度与待办事项。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] }; app.innerHTML = shell(page, '
正在读取数据
', ...meta[page]); try { const endpoint = page === 'dashboard' ? 'context' : page; const data = await api(`/api/admission/${endpoint}`); state.pageData = data; - const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : noticeTemplate(data); + const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : page === 'reporting' ? reporting(data) : noticeTemplate(data); app.innerHTML = shell(page, content, ...meta[page]); } catch (error) { renderError(error); } } function dashboard(data) { - return `
ADMISSION OFFICE

${h(data.school.name)}

学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。

本校工作入口

${data.exams.length} 场启用志愿
`; + const progress = data.plans || []; + return `
ADMISSION OFFICE

${h(data.school.name)}

学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。

${progress.length ? `
${progress.map(plan => `
${h(plan.examName)}${h(plan.progress.admissionRate)}%

计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人

实际报到完成率 ${h(plan.progress.reportingRate)}%
`).join('')}
` : ''}

本校工作入口

${data.exams.length} 场启用志愿
${data.notifications?.length ? `

系统自动通知

${data.notifications.length} 条
${data.notifications.map(notice => ``).join('')}
` : ''}
`; + } + + function reporting(data) { + if (!data.batches?.length) return `

暂无报到批次

超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。

`; + 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 rowHtml = page.items.map(item => `${h(item.name)}${h(item.candidateNumber)}${h(item.noticeNumber)}${h(item.categoryName)}`).join(''); + const actions = editable ? `
` : batch.status === 'submitted' ? `
报到情况已提交

请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。

` : `
${h(statusLabels[batch.status] || batch.status)}

${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}

`; + const ledger = `
${rowHtml || ''}
考生通知书 / 类别报到状态码备注
本轮没有正式录取考生
${pagination(page)}`; + const ledgerBlock = editable ? `
${ledger}${actions}
` : `
${ledger}
${actions}`; + return `
${h(batch.exam.code)} · 第 ${h(batch.round)} 轮

${h(batch.exam.name)}

计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。

${h(batch.progress.reportingRate)}%计划报到完成率
正式录取 ${h(batch.progress.finalCount)}已报到 ${h(batch.progress.reportedCount)}未报到 ${h(batch.progress.notReportedCount)}计划缺额 ${h(batch.progress.reportingGap)}${h(statusLabels[batch.status] || batch.status)}
${editable ? `
Excel 批量维护黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。
` : ''}${ledgerBlock}
`; + }).join(''); } function paged(items, key, defaultPageSize = 50) { @@ -37,7 +53,7 @@ export function createAdmissionViews(context) { function noticeTemplate(data) { const template = data.template || {}; - return `

模板设计

正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}

${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}
${h(template.eyebrow || 'ADMISSION NOTICE')}

${h(template.title || '录 取 通 知 书')}

${h(data.school?.name)}

张同学:

${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}

${h(template.footer || '')}${h(data.school?.name)}
录取专用章

右侧为 A4 通知书预览;正式下载件会自动写入考生、考试、录取类别及防伪查询码。

`; + return `

模板设计

正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}

${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}
${h(template.eyebrow || 'ADMISSION NOTICE')}

${h(template.title || '录 取 通 知 书')}

${h(data.school?.name)}

通知书编号:AD01-EX-2026-ZK-000001
张同学:

${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}

${h(template.footer || '')}${h(data.school?.name)}
防伪二维码

右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。

`; } function pagination(meta) { @@ -50,7 +66,7 @@ export function createAdmissionViews(context) { function plans(data) { const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20); - return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${planPage.items.map(plan => ``).join('') || ''}
考试类别计划指标分配状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${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('
') || '无定向指标'}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
${pagination(planPage)}
`; + return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${planPage.items.map(plan => ``).join('') || ''}
考试类别计划实时完成率指标分配状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${h(plan.progress?.admissionRate || 0)}%正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}实际报到 ${h(plan.progress?.reportingRate || 0)}%${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('
') || '无定向指标'}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
${pagination(planPage)}
`; } function placements(data) { @@ -60,7 +76,7 @@ export function createAdmissionViews(context) { const pendingCount = data.placements.filter(item => item.status === 'school_review').length; const placementPage = paged(data.placements, 'placementReviewTable'); const rows = placementPage.items.map(item => `${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h(item.examName)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.candidate.specialtyCertificate || '')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}`).join(''); - return `${exportBar}

本校投档审核台账

可搜索、筛选和多选批量处理;仅待审核记录可被选中。

${pendingCount} 人待审 / 共 ${data.placements.length} 人
已选 0 人
${rows || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
${pagination(placementPage)}
`; + return `${exportBar}

本校投档审核台账

可搜索、筛选和多选批量处理;仅待审核记录可被选中。

${pendingCount} 人待审 / 共 ${data.placements.length} 人
已选 0 人
${rows || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
${pagination(placementPage)}
`; } return { renderAdmission }; } diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 3e3912d..8817c15 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -31,7 +31,7 @@ export function createCandidateViews(context) { const security = ['security', '账户安全', 'user']; if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security]; if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security]; - return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['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) { @@ -40,7 +40,7 @@ export function createCandidateViews(context) { const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; const groupFor = id => role === 'candidate' ? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他') - : ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[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 => `
${h(group)}${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => ``).join('')}
`).join(''); return `
${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; @@ -110,7 +110,7 @@ export function createCandidateViews(context) { app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); try { const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations'; - const data = page === 'notices' ? { 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; if (data.profile) state.profile = data.profile; const content = { @@ -199,14 +199,14 @@ export function createCandidateViews(context) { } 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', '查看成绩'); return `${data.notifications?.length ? `
${h(data.notifications[0].payload.title)}

${h(data.notifications[0].payload.message)}

${formatDate(data.notifications[0].createdAt, true)}
` : ''}
${data.admissions.map(item => { 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 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 generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator'); const indicatorEligible = item.indicatorQualification?.payload?.eligible === true; @@ -221,7 +221,7 @@ export function createCandidateViews(context) { 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 `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join(''); const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生'; const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格'; - return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}

${h(item.payload.progress || '等待录取工作更新')}

${item.placement ? `
当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,可下载正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}
`; + return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}

${h(item.payload.progress || '等待录取工作更新')}

${item.placement ? `
当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.noticeNumber ? `录取通知书编号:${h(item.noticeNumber)}` : ''}${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}
`; }).join('')}
`; } diff --git a/src/client/pdf-export.mjs b/src/client/pdf-export.mjs index 866767c..141bc81 100644 --- a/src/client/pdf-export.mjs +++ b/src/client/pdf-export.mjs @@ -61,7 +61,24 @@ function downloadCanvasPdf(canvas, filename) { setTimeout(() => URL.revokeObjectURL(link.href), 3000); } -export function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl }) { +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'); @@ -108,12 +125,13 @@ export function downloadScoreReport({ organization, candidate, exam, results, su 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, 2250, footerY + 135, { size: 22, color: '#53627b', align: 'right', maxWidth: 1050 }); + 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 function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl }) { +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'; @@ -123,6 +141,7 @@ export function downloadAdmissionNotice({ organization, candidate, exam, placeme 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 || '招生类别'); @@ -139,12 +158,11 @@ export function downloadAdmissionNotice({ organization, candidate, exam, placeme 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' }); - ctx.beginPath(); ctx.arc(1940, 2670, 160, 0, Math.PI * 2); ctx.strokeStyle = `${primary}99`; ctx.lineWidth = 12; ctx.stroke(); - drawText(ctx, '录取专用章', 1940, 2682, { size: 34, weight: 700, color: `${primary}bb`, align: 'center' }); 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, 2160, 3185, { size: 21, color: '#71695f', align: 'right', maxWidth: 1000 }); + 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`); } diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index da23b3d..16a4caa 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -1,3 +1,5 @@ +import { filterTableItems } from './table-state.mjs'; + export function createPublicViews(context) { const { state, @@ -43,19 +45,56 @@ export function createPublicViews(context) { } 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 admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', 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, ...plans, ...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 ``; + } + + function renderPublicQualification(document) { + const key = `publicQualification-${document.documentId}`; + const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key); + return `

本公示由生源校完成全部考生资格确认后自动生成。

${page.items.map(row => ``).join('') || ''}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
没有符合条件的资格记录
${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 `

报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${page.items.map(row => ``).join('') || ''}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
没有符合条件的录取记录
${publicPagination(page)}`; } function renderDocumentBody(document) { if (document.documentType === 'notice') return `
${document.contentHtml || `

${h(document.content || '').replace(/\r?\n/g, '

')}

`}
`; if (document.documentType === 'plan') return `

招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。

${document.rows.map(row => ``).join('')}
类别代码招生类别计划人数其中定向指标指标分配
${h(row.code)}${h(row.name)}${h(row.specialtyLabel || '普通 / 政策类')}${h(row.quota)} 人${h(row.indicatorQuota || 0)} 人${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人`).join('
') : '无定向指标'}
${document.note ? `

计划说明:${h(document.note)}

` : ''}`; - if (document.documentType === 'qualification') return `

本公示由生源校完成全部考生资格确认后自动生成。

${document.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`; - if (document.documentType === 'admission') return `

报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${document.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`; + if (document.documentType === 'qualification') return renderPublicQualification(document); + if (document.documentType === 'admission') return renderPublicAdmission(document); + if (document.documentType === 'reporting') { + const stats = document.statistics || {}; + return `

本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。

招生计划${h(stats.totalQuota || 0)}
正式录取${h(stats.finalCount || 0)}
已报到${h(stats.reportedCount || 0)}
计划完成率${h(stats.reportingRate || 0)}%按实际报到

学校说明:${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}

`; + } return `

录取分数线为对应学校、招生类别最终录取考生的最低总成绩。

${document.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`; } @@ -70,13 +109,14 @@ export function createPublicViews(context) { } const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))]; 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 totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1))); state.noticeCategory = category; state.noticePage = page; const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize); - app.innerHTML = `${publicHeader()}

PUBLIC NOTICE ARCHIVE

通知公告

考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。

${h(documents.length)}份公开文件
${h(category)}第 ${h(page)} / ${h(totalPages)} 页
共 ${h(filtered.length)} 条
${pageRows.map(item => ``).join('') || '
当前分类暂无公开信息
'}
${Array.from({length:totalPages},(_,index) => index + 1).map(value => ``).join('')}
`; + app.innerHTML = `${publicHeader()}

PUBLIC NOTICE ARCHIVE

通知公告

考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。

${h(documents.length)}份公开文件
${h(category)}第 ${h(page)} / ${h(totalPages)} 页
共 ${h(filtered.length)} 条
${pageRows.map(item => ``).join('') || '
当前分类暂无公开信息
'}
${Array.from({length:totalPages},(_,index) => index + 1).map(value => ``).join('')}
`; } function renderHeroTicket(exam) { @@ -104,7 +144,7 @@ export function createPublicViews(context) { app.classList.remove('admin-readable'); const organization = state.publicData.organization || {}; const document = result?.document; - const outcome = document ? `
VERIFIED DOCUMENT

文书真实有效

该查询码由系统签发,当前数据与签发记录一致。

文书类型
${h(document.typeName)}
考生
${h(document.candidateName)}
考试
${h(document.examName)}
${document.schoolName ? `
录取学校
${h(document.schoolName)}
` : ''}${document.categoryName ? `
录取类别
${h(document.categoryName)}
` : ''}${document.totalScore != null ? `
成绩摘要
${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}
` : ''}
签发时间
${formatDate(document.issuedAt, true)}
` : error ? `
!
NOT VERIFIED

未找到有效文书

${h(error)}

` : ''; + const outcome = document ? `
VERIFIED DOCUMENT

文书真实有效

该查询码由系统签发,当前数据与签发记录一致。

文书类型
${h(document.typeName)}
${document.noticeNumber ? `
通知书编号
${h(document.noticeNumber)}
` : ''}
考生
${h(document.candidateName)}
考试
${h(document.examName)}
${document.schoolName ? `
录取学校
${h(document.schoolName)}
` : ''}${document.categoryName ? `
录取类别
${h(document.categoryName)}
` : ''}${document.totalScore != null ? `
成绩摘要
${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}
` : ''}
签发时间
${formatDate(document.issuedAt, true)}
` : error ? `
!
NOT VERIFIED

未找到有效文书

${h(error)}

` : ''; app.innerHTML = `${publicHeader()}

DOCUMENT AUTHENTICITY

文书防伪查询

输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。

${outcome}
安全提示

查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。

`; } diff --git a/src/client/ui.mjs b/src/client/ui.mjs index 1bc614d..9615b78 100644 --- a/src/client/ui.mjs +++ b/src/client/ui.mjs @@ -4,7 +4,7 @@ export const statusLabels = { open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', super: '超级管理员', school: '校级管理员', class: '班级管理员' , 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 = { diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 194707d..7e9e8b2 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,7 +1,8 @@ import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.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 { admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; +import { systemNotificationItems } from '../services/system-notifications.mjs'; export function createAdminRoutes(context) { const { @@ -166,7 +167,11 @@ export function createAdminRoutes(context) { item.payload?.publishedAt || item.updatedAt, '录取结束后由系统生成,内容取自各招生类别最低录取分数。' )); - return [...plans, ...qualifications, ...admissions, ...cutoffs] + 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, ...admissions, ...cutoffs, ...reports] .sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); } @@ -274,7 +279,7 @@ export function createAdminRoutes(context) { const userById = new Map(db.users.map(item => [item.id, item])); const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item])); const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) })); - const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) })); + 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) })); const placements = admissionRecords(db, 'placement').map(placement => { const account = userById.get(placement.userId) || {}; const profile = profileByUserId.get(placement.userId) || {}; @@ -290,7 +295,8 @@ export function createAdminRoutes(context) { const school = schoolById.get(item.schoolId); return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' }; }); - 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 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, 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 (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号'); @@ -395,20 +401,27 @@ export function createAdminRoutes(context) { return sendJson(response, 200, { ok: true, setting, placementCount: placements.length }); } if (action === 'finalize') { + if (setting.status !== 'school_review') return sendError(response, 409, '只有招生学校审核阶段可以签发录取通知书并开启报到'); const placements = admissionRecords(db, 'placement', setting.examId); if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理'); 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 } })); - setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果与分数线已经自动公告'; setting.payload.completedAt = now; - const completedDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }; - const cutoffRows = admissionCutoffRows(completedDb, setting.examId); - const existingCutoff = admissionRecords(db, '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 } }); - await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length} 人`)); - return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows }); + const reportingRecords = approvedPlans(db, setting.examId).map(plan => { + const existing = admissionReportingRecord(db, setting.examId, plan.schoolId, round); + const schoolPlacements = admitted.filter(item => item.schoolId === plan.schoolId); + const previousRows = existing?.payload?.rows || []; + const previousIds = new Set(previousRows.map(item => item.placementId)); + const rows = [...previousRows, ...schoolPlacements.filter(item => !previousIds.has(item.id)).map(item => ({ placementId: item.id, status: 'pending', note: '', updatedAt: now, source: 'system' }))]; + const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now }; + return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } }; + }); + setting.status = 'reporting'; setting.updatedAt = now; setting.payload.progress = `第 ${round} 轮录取结束,${admitted.length} 名考生已签发通知书,招生学校正在登记报到`; + await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords], logAction(db, user, '签发录取通知书并开启报到', `${setting.examId} · ${admitted.length} 人`)); + return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length }); } + if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启'); const body = await readJson(request); const now = nowIso(); if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录'); @@ -417,6 +430,67 @@ export function createAdminRoutes(context) { return sendJson(response, 200, { ok: true, setting }); } 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 (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档'); const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending'); @@ -1403,12 +1477,12 @@ export function createAdminRoutes(context) { await cache.invalidate('public'); return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) }); } - const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff)\/([^/]+)$/); + 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' }; + const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' }; const sourceType = publicationMatch[1]; - const record = admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2]); + const record = 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, '请明确设置是否显示'); diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs index e0ed049..13e46dd 100644 --- a/src/routes/admission.routes.mjs +++ b/src/routes/admission.routes.mjs @@ -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 { systemNotificationItems } from '../services/system-notifications.mjs'; function normalizeCategories(input, cleanText) { const source = Array.isArray(input) ? input : []; @@ -16,7 +17,49 @@ function normalizeCategories(input, cleanText) { } 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 }; + } async function handleAdmission(request, response, pathname) { if (!pathname.startsWith('/api/admission/')) return false; @@ -27,10 +70,12 @@ export function createAdmissionRoutes(context) { if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校'); 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') { - 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) }); } if (request.method === 'POST' && pathname === '/api/admission/plans') { @@ -80,6 +125,112 @@ export function createAdmissionRoutes(context) { 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 = []; + 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); + updates.push({ placementId: target.placementId, status: reportingStatusByCode[code], note: cleanText(item.reportingNote, 300), updatedAt: nowIso(), source: 'excel' }); + } + 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()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName }; + await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · ${updates.length} 人`)); + return sendJson(response, 200, { ok: true, count: updates.length, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, 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') { + const body = await readJson(request); + const match = String(body.code || '').toUpperCase().match(/AN-[A-F0-9]{24}/); + if (!match) return sendError(response, 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 sendError(response, 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 sendError(response, 409, '该考生不在当前可维护的报到批次'); + const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item])); + merged.set(placement.id, { placementId: placement.id, status: 'reported', note: cleanText(body.note, 300) || '扫描录取通知书二维码确认', 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}`)); + 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') { const accountById = new Map(db.users.map(item => [item.id, item])); const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item])); diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 278094d..2412588 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -1,6 +1,8 @@ import { noticeForClient } from '../security/notice-content.mjs'; import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.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) { const { @@ -61,6 +63,13 @@ export function createCandidateRoutes(context) { resolveRegion } = 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) { if (!pathname.startsWith('/api/candidate/')) return false; const user = await requireUser(request, response, 'candidate'); @@ -74,11 +83,21 @@ export function createCandidateRoutes(context) { if (request.method === 'GET' && pathname === '/api/candidate/dashboard') { 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 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) || 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 }); } + 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') { const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0]; @@ -155,24 +174,26 @@ export function createCandidateRoutes(context) { 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).map(summary => { + const summaries = await Promise.all(registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(async summary => { 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); - return { ...summary, verificationCode: registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '' }; - }); + 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 }); return sendJson(response, 200, payload); } 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 round = Number(setting.payload?.round || 1); const preference = activePreference(db, setting.examId, user.id, round); 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 plans = approvedPlans(db, setting.examId).map(plan => { + const supplementEligible = !admissionRecords(db, 'placement', setting.examId).some(item => item.userId === user.id && item.status === 'forfeited'); + const plans = (supplementEligible ? approvedPlans(db, setting.examId) : []).map(plan => { 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'); return { @@ -196,8 +217,9 @@ export function createCandidateRoutes(context) { const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null; const noticeTemplate = templateRecord?.payload?.template || null; const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : ''; - return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, 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 }; - }).filter(item => item.exam); + const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : ''; + return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, 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); return sendJson(response, 200, { ok: true, admissions: settings, notifications }); } @@ -206,6 +228,7 @@ export function createCandidateRoutes(context) { const setting = admissionSetting(db, preferenceMatch[1]); if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报'); if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段'); + if (setting.status === 'supplementary' && admissionRecords(db, 'placement', setting.examId).some(item => item.userId === user.id && item.status === 'forfeited')) return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录'); const now = Date.now(); 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, '志愿填报已经截止'); diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index bffef91..b559360 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -1,6 +1,7 @@ import { noticeForClient } from '../security/notice-content.mjs'; import { admissionRecords, 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) { const { @@ -76,7 +77,7 @@ export function createPublicRoutes(context) { 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: '录取通知书', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } }); + 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, '未查询到有效文书,请核对防伪码'); @@ -84,7 +85,9 @@ export function createPublicRoutes(context) { if (pathname === '/api/public/home') { const payload = await cache.remember('public', 'home', async () => { 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 })); 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 } }; }); @@ -120,7 +123,8 @@ export function createPublicRoutes(context) { })).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 && item.payload?.publicVisible !== 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 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)); - return { ok: true, plans, qualifications, admissions, cutoffs }; + 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); } @@ -129,7 +133,9 @@ export function createPublicRoutes(context) { const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => { const db = await readDb(); 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, '通知不存在或尚未发布'); } diff --git a/src/security/document-verification.mjs b/src/security/document-verification.mjs index 896855f..cd8cde1 100644 --- a/src/security/document-verification.mjs +++ b/src/security/document-verification.mjs @@ -18,7 +18,7 @@ export function scoreReportCode(secret, registration, exam, results = []) { } export function admissionNoticeCode(secret, placement, exam) { - return `AN-${signature(secret, 'admission-notice', [placement.id, placement.userId, placement.schoolId, exam.id, placement.payload?.categoryCode || '', placement.updatedAt || ''])}`; + 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) { diff --git a/src/services/system-notifications.mjs b/src/services/system-notifications.mjs new file mode 100644 index 0000000..22e6444 --- /dev/null +++ b/src/services/system-notifications.mjs @@ -0,0 +1,59 @@ +import { admissionRecords, 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, + `

${h(schoolName(record.schoolId))}招生计划已经审核通过。

` + )); + 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, + `

${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。

` + )); + 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, + `

${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。

` + )); + 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, + `

系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。

` + )); + 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, + `

${h(summary)}

${h(decision)}

${h(record.payload?.approvalNote || '')}

`); + }); + return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports] + .sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt)); +} + diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs index d5ebbfe..7d86b16 100644 --- a/src/services/volunteer-admission.mjs +++ b/src/services/volunteer-admission.mjs @@ -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) { return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId)); @@ -65,6 +65,89 @@ export function planSummary(plan) { return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) }; } +export function admissionReportingRecords(db, examId, schoolId) { + 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) { return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => { const user = db.users.find(entry => entry.id === item.userId) || {}; @@ -122,7 +205,8 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { 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 existing = allExisting.filter(item => !['withdrawn', 'forfeited'].includes(item.status)); const occupiedIndicators = new Map(); const occupiedGeneral = new Map(); for (const placement of existing) { @@ -139,7 +223,7 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { const account = db.users.find(item => item.id === preference.userId) || {}; const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved'); return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 }; - }).filter(item => item.culturalScore != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status))); + }).filter(item => item.culturalScore != null && !allExisting.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(entry.status))); const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || '')); const acceptedByBucket = new Map(); @@ -208,7 +292,7 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { export function remainingPlanQuota(db, plan) { 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) }; }); } diff --git a/styles.css b/styles.css index 4abe474..e859b55 100644 --- a/styles.css +++ b/styles.css @@ -921,7 +921,6 @@ body.review-subpage-open { overflow:hidden; } .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; } -.template-frame > i { position: absolute; right: 72px; bottom: 85px; width: 110px; height: 110px; display: grid; place-items: center; border: 5px solid color-mix(in srgb, var(--template-primary), transparent 25%); border-radius: 50%; color: var(--template-primary); font-size: 13px; font-style: normal; font-weight: 800; transform: rotate(-8deg); } .notice-template-preview > p { color: var(--muted); font-size: 11px; line-height: 1.7; } /* 公开防伪查询 */ @@ -946,9 +945,59 @@ body.review-subpage-open { overflow:hidden; } .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 auto auto minmax(360px,1.3fr); gap:12px; align-items:center; margin:20px 24px 0; padding:17px; border:1px solid #ead9a5; border-radius:10px; background:#fffaf0; } +.reporting-tools > div { display:grid; gap:3px; } +.reporting-tools small { color:#7d735e; } +.reporting-tools form { display:flex; gap:8px; } +.reporting-tools form input { min-width:220px; } +.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-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; } +.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 auto auto; } + .reporting-tools > div,.reporting-tools form { grid-column:1/-1; } + .reporting-tools form { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; } + .reporting-decision { grid-template-columns:1fr 1fr; } } @media (max-width: 850px) { .portal-sidebar { width: 264px; } @@ -962,4 +1011,11 @@ body.review-subpage-open { overflow:hidden; } .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-actions { align-items:stretch; flex-direction:column; } } diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs index a7b9a01..97179af 100644 --- a/tests/admission.test.mjs +++ b/tests/admission.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; -import { admissionCutoffRows, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs'; +import { admissionCutoffRows, admissionPlanProgress, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.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'; let sequence = 0; @@ -88,4 +89,24 @@ const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source- assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件'); 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); +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('志愿投档、指标名额与脱敏公示测试通过'); diff --git a/tests/document-verification.test.mjs b/tests/document-verification.test.mjs index d144bd4..0d6f732 100644 --- a/tests/document-verification.test.mjs +++ b/tests/document-verification.test.mjs @@ -21,13 +21,15 @@ assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false); const placement = { id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1', - payload: { categoryCode: 'general' }, updatedAt: '2026-07-21T08:00:00.000Z' + 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' }), diff --git a/tests/system.test.mjs b/tests/system.test.mjs index e68342f..18caef3 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -12,6 +12,7 @@ import { mysqlSchema } from '../src/database/schema.mjs'; import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs'; import { totpAtStep } from '../src/security/totp.mjs'; import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs'; +import { admissionNoticeCode } from '../src/security/document-verification.mjs'; const root = resolve(process.cwd()); assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果'); @@ -598,7 +599,8 @@ try { assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); 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: [ { code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] }, { code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] } @@ -657,6 +659,7 @@ try { 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'); @@ -675,6 +678,51 @@ try { 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 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 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 scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { code: `http://127.0.0.1/#verify/${noticeCode}` } }); + assert.equal(scannedReporting.response.status, 200, '招生学校应能扫描通知书防伪二维码并暂存报到'); + assert.equal(scannedReporting.data.row.status, 'reported', '扫码核验通过后应暂存为已报到而不是直接提交'); + + const secondRow = reportingBatch.rows[1]; + 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((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 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, '海州市招生实验学校', '招生学校账户台账应显示绑定学校'); @@ -1026,6 +1074,7 @@ try { const results = await candidate.request('/api/candidate/results'); assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); 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.total, 216, '考生端应汇总已报科目的总分'); assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');