全站大数据台账补齐跨页搜索、筛选和分页,包括投档监督、报到审批、成绩复议、公开录取名单等。
后台重构为一级业务域,并将招生录取拆为 5 个二级菜单。 修复成绩等级排名遮挡科目。 成绩单、录取通知书均支持带防伪码和二维码的 PDF 下载。 录取通知书移除“录取专用章”,支持学校自定义模板。 通知书编号采用“学校代码 + 考试代码 + 学校独立流水号”。 完成招生学校报到台账:Y/N/P 状态、暂存、Excel 导入导出、二维码扫描确认。 完成报到提交、计划完成率、补录决定、超级管理员审批和自动公开闭环。 自动公告现已进入公开首页、考生通知及招生学校工作台。 验证结果: 完整 npm test 全部通过。 浏览器端三个角色工作台验收通过。 Excel 模板已实际导出并渲染验证。 两类 PDF 已实际生成、转图检查,二维码、编号和无印章版式正常。
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#132451" />
|
||||
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
|
||||
<title>衡准 · 考试信息管理系统</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="stylesheet" href="/styles.css?v=20260722" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" aria-live="polite">
|
||||
@@ -21,6 +21,6 @@
|
||||
<span class="toast-icon">✓</span>
|
||||
<div><strong>操作成功</strong><small>更改已保存</small></div>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script type="module" src="/app.js?v=20260722"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+35
-18
File diff suppressed because one or more lines are too long
@@ -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, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'context' : page;
|
||||
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
|
||||
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : 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 `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button></section></div>`;
|
||||
const progress = data.plans || [];
|
||||
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section>${progress.length ? `<section class="admission-progress-grid">${progress.map(plan => `<article><header><span>${h(plan.examName)}</span><strong>${h(plan.progress.admissionRate)}%</strong></header><div class="progress-meter"><i style="width:${Math.min(100, plan.progress.admissionRate)}%"></i></div><p>计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人</p><small>实际报到完成率 ${h(plan.progress.reportingRate)}%</small></article>`).join('')}</section>` : ''}<div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button><button data-route="admission_school/reporting"><i>到</i><span><strong>登记考生报到</strong><small>暂存、Excel 导入或扫描通知书二维码</small></span>${icons.arrow}</button></section>${data.notifications?.length ? `<section class="panel compact-notices"><div class="panel-title"><h2>系统自动通知</h2><span>${data.notifications.length} 条</span></div>${data.notifications.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section>` : ''}</div>`;
|
||||
}
|
||||
|
||||
function reporting(data) {
|
||||
if (!data.batches?.length) return `<section class="panel empty-state"><h2>暂无报到批次</h2><p>超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。</p></section>`;
|
||||
const statusLabels = { draft: '暂存中', submitted: '报到已提交', pending_approval: '补录决定待审批', approved: '已审批并公示', rejected: '审批退回', not_started: '尚未开始' };
|
||||
return data.batches.map(batch => {
|
||||
const key = `reporting-${batch.exam.id}-${batch.round}`;
|
||||
const page = paged(batch.rows, key, 20);
|
||||
const editable = ['draft', 'rejected'].includes(batch.status);
|
||||
const rowHtml = page.items.map(item => `<tr><td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
|
||||
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
|
||||
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || '<tr><td colspan="4" class="empty-state">本轮没有正式录取考生</td></tr>'}</tbody></table></div>${pagination(page)}`;
|
||||
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
|
||||
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label><form data-form="admission-reporting-scan"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="粘贴 AN 防伪码或二维码核验链接" required><button class="ghost-button" type="submit">扫码结果暂存为已报到</button><label class="qr-capture">拍摄二维码<input type="file" accept="image/*" capture="environment" data-reporting-qr-file hidden></label></form></section>` : ''}${ledgerBlock}</section>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function paged(items, key, defaultPageSize = 50) {
|
||||
@@ -37,7 +53,7 @@ export function createAdmissionViews(context) {
|
||||
|
||||
function noticeTemplate(data) {
|
||||
const template = data.template || {};
|
||||
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><i>录取专用章</i></div><p>右侧为 A4 通知书预览;正式下载件会自动写入考生、考试、录取类别及防伪查询码。</p></article></section>`;
|
||||
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><div class="template-notice-number">通知书编号:AD01-EX-2026-ZK-000001</div><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><div class="template-qr-placeholder">防伪二维码</div></div><p>右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。</p></article></section>`;
|
||||
}
|
||||
|
||||
function pagination(meta) {
|
||||
@@ -50,7 +66,7 @@ export function createAdmissionViews(context) {
|
||||
|
||||
function plans(data) {
|
||||
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
|
||||
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolAdmissionPlanTable" placeholder="跨页搜索考试、类别、指标学校或审核意见"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
}
|
||||
|
||||
function placements(data) {
|
||||
@@ -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 => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
|
||||
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="搜索当前页的姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="跨页搜索姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
}
|
||||
return { renderAdmission };
|
||||
}
|
||||
|
||||
@@ -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 => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||
@@ -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 ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
|
||||
const 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 `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('');
|
||||
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
|
||||
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
|
||||
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,可下载正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
|
||||
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}—${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button><button type="button" class="active" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page}">${meta.page}</button><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
|
||||
}
|
||||
|
||||
function renderPublicQualification(document) {
|
||||
const key = `publicQualification-${document.documentId}`;
|
||||
const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key);
|
||||
return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button data-action="status-filter" data-target="${h(key)}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${h(key)}" data-status="ineligible">无资格</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${page.items.map(row => `<tr data-status="${h(row.status)}"><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">没有符合条件的资格记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
|
||||
}
|
||||
|
||||
function renderPublicAdmission(document) {
|
||||
const key = `publicAdmission-${document.documentId}`;
|
||||
const page = publicPaged(document.rows, key);
|
||||
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
|
||||
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
|
||||
return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
|
||||
}
|
||||
|
||||
function renderDocumentBody(document) {
|
||||
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
|
||||
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
|
||||
if (document.documentType === 'qualification') return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
if (document.documentType === 'admission') return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
if (document.documentType === 'qualification') return renderPublicQualification(document);
|
||||
if (document.documentType === 'admission') return renderPublicAdmission(document);
|
||||
if (document.documentType === 'reporting') {
|
||||
const stats = document.statistics || {};
|
||||
return `<p class="document-lead">本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。</p><div class="reporting-public-stats"><article><span>招生计划</span><strong>${h(stats.totalQuota || 0)}</strong><small>人</small></article><article><span>正式录取</span><strong>${h(stats.finalCount || 0)}</strong><small>人</small></article><article><span>已报到</span><strong>${h(stats.reportedCount || 0)}</strong><small>人</small></article><article><span>计划完成率</span><strong>${h(stats.reportingRate || 0)}%</strong><small>按实际报到</small></article></div><p class="document-note"><strong>学校说明:</strong>${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}</p>`;
|
||||
}
|
||||
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
@@ -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()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}>下一页</button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>录取公示为通知公告中的公开类别</span></footer>`;
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="data-toolbar notice-directory-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="publicNoticeDirectory" placeholder="搜索全部通知、公示标题、分类或摘要"></label><button class="row-action" data-action="clear-table-filters" data-target="publicNoticeDirectory">清除搜索</button></div><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}>下一页</button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>录取公示为通知公告中的公开类别</span></footer>`;
|
||||
}
|
||||
|
||||
function renderHeroTicket(exam) {
|
||||
@@ -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 ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div><div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
|
||||
const outcome = document ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div>${document.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(document.noticeNumber)}</dd></div>` : ''}<div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 = {
|
||||
|
||||
+90
-16
@@ -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, '请明确设置是否显示');
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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, '志愿填报已经截止');
|
||||
|
||||
@@ -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, '通知不存在或尚未发布');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
`<p>${h(schoolName(record.schoolId))}招生计划已经审核通过。</p><ul>${(record.payload?.categories || []).map(category => `<li>${h(category.name)}:${Number(category.quota || 0)} 人</li>`).join('')}</ul>`
|
||||
));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(record => record.status === 'published').map(record => item(
|
||||
record, 'qualification', '指标资格', `${examName(record.examId)} · ${schoolName(record.schoolId)}指标分配资格公示`,
|
||||
'生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt,
|
||||
`<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>`
|
||||
));
|
||||
const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item(
|
||||
record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`,
|
||||
'录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt,
|
||||
`<p>${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。</p>`
|
||||
));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(record => record.status === 'published' && admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
|
||||
record, 'cutoff', '录取分数线', `${examName(record.examId)}录取分数线`,
|
||||
'各招生学校和类别录取分数线已经由系统汇总发布。', record.payload?.publishedAt || record.updatedAt,
|
||||
`<p>系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。</p>`
|
||||
));
|
||||
const reports = admissionRecords(db, 'notification').filter(record => record.userId == null && record.status === 'approved' && record.payload?.type === 'admission_reporting').map(record => {
|
||||
const stats = record.payload?.statistics || {};
|
||||
const supplement = record.payload?.supplementDecision === 'supplement';
|
||||
const title = supplement
|
||||
? `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况及补录说明`
|
||||
: `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况公示`;
|
||||
const summary = `计划 ${Number(stats.totalQuota || 0)} 人,已报到 ${Number(stats.reportedCount || 0)} 人,完成率 ${Number(stats.reportingRate || 0)}%。`;
|
||||
const decision = supplement ? '学校申请补录并已获批准。' : (record.payload?.decisionNote || '本轮不进行补录。');
|
||||
return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt,
|
||||
`<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`);
|
||||
});
|
||||
return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports]
|
||||
.sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'supplementary', 'completed']);
|
||||
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'reporting', 'supplementary', 'completed']);
|
||||
|
||||
export function admissionRecords(db, kind, examId = null) {
|
||||
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) };
|
||||
});
|
||||
}
|
||||
|
||||
+57
-1
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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('志愿投档、指标名额与脱敏公示测试通过');
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
+50
-1
@@ -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, '考生总分满分应按实际报考科目汇总');
|
||||
|
||||
Reference in New Issue
Block a user