全站大数据台账补齐跨页搜索、筛选和分页,包括投档监督、报到审批、成绩复议、公开录取名单等。
后台重构为一级业务域,并将招生录取拆为 5 个二级菜单。 修复成绩等级排名遮挡科目。 成绩单、录取通知书均支持带防伪码和二维码的 PDF 下载。 录取通知书移除“录取专用章”,支持学校自定义模板。 通知书编号采用“学校代码 + 考试代码 + 学校独立流水号”。 完成招生学校报到台账:Y/N/P 状态、暂存、Excel 导入导出、二维码扫描确认。 完成报到提交、计划完成率、补录决定、超级管理员审批和自动公开闭环。 自动公告现已进入公开首页、考生通知及招生学校工作台。 验证结果: 完整 npm test 全部通过。 浏览器端三个角色工作台验收通过。 Excel 模板已实际导出并渲染验证。 两类 PDF 已实际生成、转图检查,二维码、编号和无印章版式正常。
This commit is contained in:
1 parent
d029931c31
commit
3e1bced990
22 files changed
+813
-94
No files matched your search
@@ -23,6 +23,8 @@
|
|||||||
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
|
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
|
||||||
- 准考证生成状态、开放时间与下载
|
- 准考证生成状态、开放时间与下载
|
||||||
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
||||||
|
- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单
|
||||||
|
- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书
|
||||||
- 通知公告中心
|
- 通知公告中心
|
||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
@@ -269,11 +271,14 @@ npm run seed-test-data:mysql -- --force
|
|||||||
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
|
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
|
||||||
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
|
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
|
||||||
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
||||||
7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。
|
7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。
|
||||||
|
8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。
|
||||||
|
9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。
|
||||||
|
10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。
|
||||||
|
|
||||||
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
||||||
|
|
||||||
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。
|
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。
|
||||||
|
|
||||||
数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
||||||
|
|
||||||
|
|||||||
@@ -345,13 +345,13 @@ document.addEventListener('click', async event => {
|
|||||||
const results = (state.pageData?.results || []).filter(item => item.examId === examId);
|
const results = (state.pageData?.results || []).filter(item => item.examId === examId);
|
||||||
const summary = (state.pageData?.summaries || []).find(item => item.examId === examId);
|
const summary = (state.pageData?.summaries || []).find(item => item.examId === examId);
|
||||||
if (!results.length || !summary?.verificationCode) return toast('成绩单暂不可下载', '请刷新页面后重试');
|
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 成绩单已生成', '文件包含防伪查询码');
|
return toast('PDF 成绩单已生成', '文件包含防伪查询码');
|
||||||
}
|
}
|
||||||
if (action === 'download-admission-notice') {
|
if (action === 'download-admission-notice') {
|
||||||
const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId);
|
const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId);
|
||||||
if (!item?.placement || item.placement.status !== 'final' || !item.noticeVerificationCode) return toast('录取通知书暂不可下载', '只有正式录取后才能生成');
|
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('录取通知书已生成', '请核对学校和录取类别');
|
return toast('录取通知书已生成', '请核对学校和录取类别');
|
||||||
}
|
}
|
||||||
if (action === 'download-admitted-candidates') {
|
if (action === 'download-admitted-candidates') {
|
||||||
@@ -405,6 +405,30 @@ document.addEventListener('click', async event => {
|
|||||||
updateQualificationSelection(container);
|
updateQualificationSelection(container);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'download-admission-reporting') {
|
||||||
|
window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === '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') {
|
if (action === 'clear-table-filters') {
|
||||||
const tableId = target.dataset.target;
|
const tableId = target.dataset.target;
|
||||||
state.tableFilters[tableId] = { query: '', status: 'all', filters: {} };
|
state.tableFilters[tableId] = { query: '', status: 'all', filters: {} };
|
||||||
@@ -1014,6 +1038,24 @@ document.addEventListener('submit', async event => {
|
|||||||
} else if (kind === 'admission-notice-template') {
|
} else if (kind === 'admission-notice-template') {
|
||||||
await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) });
|
await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) });
|
||||||
toast('录取通知书模板已保存', '正式录取考生将使用该模板生成 PDF'); renderRoute();
|
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') {
|
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
|
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
|
||||||
@@ -1422,6 +1464,39 @@ window.addEventListener('keydown', event => {
|
|||||||
form.requestSubmit(form.querySelector('[data-result-mode="draft"]'));
|
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 {
|
try {
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ const resourceSpecs = {
|
|||||||
],
|
],
|
||||||
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
||||||
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||||
|
},
|
||||||
|
admission_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' };
|
header.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
||||||
for (const item of outputRows) {
|
for (const item of outputRows) {
|
||||||
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? ''])));
|
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, resource === 'admission_reporting' && key === 'reportingNote' && !item[key] ? null : item[key] ?? ''])));
|
||||||
row.height = 23;
|
row.height = 23;
|
||||||
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
||||||
row.alignment = { vertical: 'middle' };
|
row.alignment = { vertical: 'middle' };
|
||||||
@@ -208,6 +219,14 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
|||||||
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (resource === 'admission_reporting') {
|
||||||
|
const statusColumn = spec.columns.findIndex(([key]) => key === 'reportingStatusCode') + 1;
|
||||||
|
const noteColumn = spec.columns.findIndex(([key]) => key === 'reportingNote') + 1;
|
||||||
|
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
|
||||||
|
for (const column of [statusColumn, noteColumn]) sheet.getCell(row, column).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF3CD' } };
|
||||||
|
}
|
||||||
|
sheet.getCell('A1').value = `${spec.title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认`;
|
||||||
|
}
|
||||||
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
||||||
for (const [key, values] of Object.entries(spec.validations || {})) {
|
for (const [key, values] of Object.entries(spec.validations || {})) {
|
||||||
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
||||||
|
|||||||
Loaded 3 of 22 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user