全站大数据台账补齐跨页搜索、筛选和分页,包括投档监督、报到审批、成绩复议、公开录取名单等。

后台重构为一级业务域,并将招生录取拆为 5 个二级菜单。
修复成绩等级排名遮挡科目。
成绩单、录取通知书均支持带防伪码和二维码的 PDF 下载。
录取通知书移除“录取专用章”,支持学校自定义模板。
通知书编号采用“学校代码 + 考试代码 + 学校独立流水号”。
完成招生学校报到台账:Y/N/P 状态、暂存、Excel 导入导出、二维码扫描确认。
完成报到提交、计划完成率、补录决定、超级管理员审批和自动公开闭环。
自动公告现已进入公开首页、考生通知及招生学校工作台。
验证结果:
完整 npm test 全部通过。
浏览器端三个角色工作台验收通过。
Excel 模板已实际导出并渲染验证。
两类 PDF 已实际生成、转图检查,二维码、编号和无印章版式正常。
This commit is contained in:
biss committed 2026-07-22 09:50:52 +08:00
1 parent d029931c31
commit 3e1bced990
22 files changed
+813 -94

No files matched your search

+7 -2
View File
@@ -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`。
+77 -2
View File
@@ -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 {
+20 -1
View File
@@ -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;
Loaded 3 of 22 files, more files were not shown because too many files have changed in this diff. Show more