diff --git a/README.md b/README.md
index 81b7a59..e881cb1 100644
--- a/README.md
+++ b/README.md
@@ -260,18 +260,19 @@ npm run seed-test-data:mysql -- --force
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
-1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
+1. 超级管理员设置填报时间、普通志愿数、最多提交次数和当前阶段;考生只有在当次成绩全部发布后才能填报,达到提交上限后自动锁定。
2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
-3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
-4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
-5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
-6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
+3. 生源校学校管理员按考试逐人确认指标分配资格;本校资料已完善的在册考生全部确认后,系统自动公开有无资格及对应特长类型。超级管理员和班级管理员均不能代确认。
+4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
+5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
+6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
+7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。
-数据结构版本为 v19,`admission_records` 关系表支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
+数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
## 手动测试数据账号
diff --git a/app.js b/app.js
index da8fb01..5a2d5f0 100644
--- a/app.js
+++ b/app.js
@@ -59,7 +59,7 @@ function renderError(error) {
}
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
-const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
+const { brand, renderHome, renderAnnouncements, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
@@ -75,6 +75,7 @@ async function renderRoute() {
const route = location.hash.slice(1) || 'home';
const [section, page = 'dashboard'] = route.split('/');
if (section === 'home') renderHome();
+ else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); }
else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page);
@@ -221,6 +222,14 @@ document.addEventListener('click', async event => {
await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
}
+ if (action === 'save-indicator-qualification') {
+ const row = target.closest('tr');
+ const value = row?.querySelector('[data-indicator-eligible]')?.value;
+ if (!value) return toast('请选择资格结论', '必须明确选择有资格或无资格');
+ const data = await api(`/api/admin/indicator-qualifications/${row.dataset.examId}/${row.dataset.userId}`, { method: 'PUT', body: { eligible: value === 'true' } });
+ toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生');
+ return renderRoute();
+ }
if (action === 'add-admission-category') {
const sources = state.pageData?.sourceSchools || [];
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
@@ -510,9 +519,10 @@ document.addEventListener('change', event => {
const categorySelect = row?.querySelector('[name="choiceCategory"]');
const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId);
const plan = admission?.plans?.find(item => item.schoolId === event.target.value);
+ const preferenceType = row?.dataset.preferenceType || 'general';
if (categorySelect) {
categorySelect.disabled = !plan;
- categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.remaining > 0).map(item => ``).join('')}`;
+ categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => ``).join('')}`;
}
}
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
@@ -617,9 +627,9 @@ document.addEventListener('submit', async event => {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
} else if (kind === 'volunteer-preference') {
- const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value })).filter(item => item.schoolId && item.categoryCode);
- await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
- toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
+ const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value, preferenceType: row.dataset.preferenceType || 'general' })).filter(item => item.schoolId && item.categoryCode);
+ const data = await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
+ toast(data.locked ? '志愿已保存并锁定' : '志愿已保存', data.locked ? '已达到本轮提交次数上限' : `还可提交 ${data.remainingSubmissions} 次`); renderRoute();
} else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -642,7 +652,7 @@ document.addEventListener('submit', async event => {
await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
} else if (kind === 'admission-setting') {
- const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5);
+ const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); body.maxSubmissions = Number(body.maxSubmissions || 3);
await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body });
toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute();
} else if (kind === 'admission-account') {
diff --git a/database.mjs b/database.mjs
index 134d600..9f124ee 100644
--- a/database.mjs
+++ b/database.mjs
@@ -60,7 +60,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
add(
- 'UPDATE schema_metadata SET schema_version = 19, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
+ 'UPDATE schema_metadata SET schema_version = 20, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 44e378e..da5b702 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -47,6 +47,7 @@ export function createAdminViews(context) {
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'],
admissions: ['招生录取', '设置志愿窗口、审核招生计划,并按分数优先、遵循志愿执行投档、退档审核和补录。'],
+ 'indicator-qualifications': ['指标分配资格确认', '由生源校逐人确认;本校全部考生确认后,系统自动发布资格公示。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
const allowedPages = adminNavForUser().map(item => item[0]);
@@ -60,7 +61,7 @@ export function createAdminViews(context) {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
schools: () => adminSchoolsV2(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
- 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), security: () => accountSecurity(data)
+ 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
if (page === 'admissions') mountPreferenceLedger(data);
@@ -166,7 +167,7 @@ export function createAdminViews(context) {
const pendingPlans = data.plans.filter(item => item.status === 'pending');
const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending');
const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' };
- const settings = `考试志愿设置
成绩发布后按场次开放,并持续向考生展示录取进度。
${selected ? `` : ''}`;
+ const settings = `考试志愿设置
成绩发布后按场次开放,并持续向考生展示录取进度。
${selected ? `` : ''}`;
const accounts = `招生学校账号
只可绑定已在学校管理中标记为“招生校”的学校。
${data.schoolAccounts.length} 个 `;
const planForm = `代招生校上传计划
每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。
`;
const plans = `${pendingPlans.length} 份待审 `;
@@ -174,6 +175,11 @@ export function createAdminViews(context) {
return `ADMISSION COMMAND中考招生录取控制台
学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。
- 待审计划
- ${pendingPlans.length}
学校审核中${data.placements.filter(item => item.status === 'school_review').length}退档待审${withdrawals.length}正式录取${data.placements.filter(item => item.status === 'final').length}${settings}${accounts}
${planForm}${plans}${placements}`;
}
+ function adminIndicatorQualifications(data) {
+ if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。');
+ return `SOURCE SCHOOL CERTIFICATION${h(data.school?.name)}资格确认簿
确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。
${data.exams.map(item => { const status = item.qualificationStatus; return `
${status.complete ? '✓ 本校资格已全部确认,公开公示已自动发布
' : '未全部确认前不会公开,请逐项核对。
'}`; }).join('')}`;
+ }
+
function adminNotices(notices) {
return ``;
}
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index c5eb7d1..b788c0c 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -30,7 +30,7 @@ export function createCandidateViews(context) {
const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
- if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
+ if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
}
@@ -198,19 +198,25 @@ export function createCandidateViews(context) {
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
return `${data.notifications?.length ? `${h(data.notifications[0].payload.title)}${h(data.notifications[0].payload.message)}
${formatDate(data.notifications[0].createdAt, true)}` : ''}${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
- const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null;
+ const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked;
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 choiceRows = Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => {
- const choice = choices[index] || {};
- const plan = item.plans.find(entry => entry.schoolId === choice.schoolId);
- const categoryOptions = (plan?.categories || []).filter(category => category.remaining > 0 || category.code === choice.categoryCode);
- return `
${index + 1}
`;
- }).join('');
- const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `
${index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join('');
+ const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
+ const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
+ const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
+ const choiceRow = (choice, preferenceType, index) => {
+ const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType)));
+ const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId);
+ const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode));
+ const disabled = preferenceType === 'indicator' && !indicatorEligible;
+ return `
${preferenceType === 'indicator' ? '指标' : index + 1}
`;
+ };
+ const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join('');
+ const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `
${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
- return `
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}资格 ${h(qualification)}${h(phaseLabels[item.status] || item.status)}
${h(item.payload.progress || '等待录取工作更新')}
${item.placement ? `当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}
` : ''}${canFill ? `` : choices.length ? `已锁定志愿顺序${lockedRows}
` : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}`;
+ const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
+ return `
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}
${h(item.payload.progress || '等待录取工作更新')}
${item.placement ? `当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}
` : ''}${canFill ? `` : choices.length ? `${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}`;
}).join('')}
`;
}
diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs
index 9e9bba9..57a77f4 100644
--- a/src/client/public-views.mjs
+++ b/src/client/public-views.mjs
@@ -20,12 +20,12 @@ export function createPublicViews(context) {
}
function publicHeader() {
- return ``;
+ return ``;
}
function renderHome() {
app.classList.remove('admin-readable');
- const { notices, exams, stats, organization, admissionAnnouncements = [] } = state.publicData;
+ const { notices, exams, stats, organization } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0];
@@ -37,11 +37,23 @@ export function createPublicViews(context) {
报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。
${topNotice ? `${h(topNotice.category)}${h(topNotice.title)}
${h(topNotice.summary)}
` : '暂无通知
'}${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
- ${admissionAnnouncements.length ? `ADMISSION DISCLOSURE
录取结果公示
报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。
${admissionAnnouncements.map(announcement => ``).join('')}` : ''}
+ ADMISSION DISCLOSURE
招生录取公开卷宗
指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。
${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
报名号不会随考试改变,每场考试只新增一条报名记录。
${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}${item[1]}
${item[2]}
`).join('')}
`;
}
+
+ function renderAnnouncements(data = state.publicAnnouncements) {
+ app.classList.remove('admin-readable');
+ const organization = state.publicData.organization || {};
+ const sections = [data.qualifications?.length, data.admissions?.length, data.cutoffs?.length].filter(Boolean).length;
+ app.innerHTML = `${publicHeader()}PUBLIC ADMISSION LEDGER
招生录取
公开卷宗
按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。
- 公开类别
- ${sections}
- 资格公示
- ${h(data.qualifications?.length || 0)}
- 录取公告
- ${h(data.admissions?.length || 0)}
+
+ QUALIFICATION REGISTER
指标分配资格公示
仅在生源校全部考生完成资格确认后自动发布。
${(data.qualifications || []).map(item => ``).join('') || '暂无已完成全校确认的资格公示
'}
+ 录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。
${(data.admissions || []).map(item => ``).join('') || '暂无已完成的录取公告
'}
+ 分数线为对应学校、招生类别最终录取考生的最低总成绩。
${(data.cutoffs || []).map(item => ``).join('') || '暂无已发布的录取分数线
'}
+ `;
+ }
function renderHeroTicket(exam) {
const status = exam.registrationState;
@@ -72,5 +84,5 @@ export function createPublicViews(context) {
return ``;
}
- return { brand, renderHome, renderAuth };
+ return { brand, renderHome, renderAnnouncements, renderAuth };
}
diff --git a/src/client/state.mjs b/src/client/state.mjs
index 4932421..f5a1a00 100644
--- a/src/client/state.mjs
+++ b/src/client/state.mjs
@@ -1,7 +1,8 @@
export const state = {
user: null,
profile: null,
- publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} },
+ publicData: { organization: {}, notices: [], exams: [], stats: {} },
+ publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] },
permissions: [],
scopeLabel: '',
pageData: null,
diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs
index 24ac565..5c98b43 100644
--- a/src/database/mysql-adapter.mjs
+++ b/src/database/mysql-adapter.mjs
@@ -54,7 +54,7 @@ export function createMysqlAdapter(context) {
hasSchemaMetadata = metadataRows.length > 0;
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
}
- if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19].includes(existingSchemaVersion))) {
+ if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19, 20].includes(existingSchemaVersion))) {
for (const table of [...mysqlTableNames].reverse()) {
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
}
@@ -191,6 +191,11 @@ export function createMysqlAdapter(context) {
await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1');
metadataRows[0].schema_version = 19;
}
+ if (Number(metadataRows[0]?.schema_version || 1) < 20) {
+ await pool.query("ALTER TABLE admission_records MODIFY COLUMN kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL");
+ await pool.execute('UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1');
+ metadataRows[0].schema_version = 20;
+ }
if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed();
const connection = await pool.getConnection();
diff --git a/src/database/schema.mjs b/src/database/schema.mjs
index 5482e47..7edd605 100644
--- a/src/database/schema.mjs
+++ b/src/database/schema.mjs
@@ -436,7 +436,7 @@ export const sqliteSchema = `
CREATE TABLE IF NOT EXISTS admission_records (
id TEXT PRIMARY KEY,
- kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification')),
+ kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
@@ -1018,7 +1018,7 @@ export const mysqlSchema = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS admission_records (
id VARCHAR(64) NOT NULL,
- kind ENUM('setting', 'plan', 'preference', 'placement', 'notification') NOT NULL,
+ kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL,
exam_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NULL,
school_id VARCHAR(64) NULL,
diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs
index c026afd..bc8d405 100644
--- a/src/database/sqlite-adapter.mjs
+++ b/src/database/sqlite-adapter.mjs
@@ -287,6 +287,30 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.schema_version || 1) < 19) {
connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run();
}
+ if (existingSystem && Number(existingSystem.schema_version || 1) < 20) {
+ connection.exec(`
+ PRAGMA foreign_keys = OFF;
+ BEGIN IMMEDIATE;
+ CREATE TABLE admission_records_v20 (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
+ exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
+ school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
+ status TEXT NOT NULL,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ ) STRICT;
+ INSERT INTO admission_records_v20 SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records;
+ DROP TABLE admission_records;
+ ALTER TABLE admission_records_v20 RENAME TO admission_records;
+ CREATE INDEX idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
+ UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1;
+ COMMIT;
+ PRAGMA foreign_keys = ON;
+ `);
+ }
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 30496ac..44cf998 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -1,6 +1,6 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
-import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
+import { admissionCutoffRows, admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
export function createAdminRoutes(context) {
@@ -141,6 +141,44 @@ export function createAdminRoutes(context) {
});
}
+ if (pathname === '/api/admin/indicator-qualifications' && request.method === 'GET') {
+ if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
+ const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isSourceSchool);
+ if (!school) return sendError(response, 403, '当前学校未设置为生源学校');
+ const exams = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
+ const exam = db.exams.find(item => item.id === setting.examId);
+ return { ...setting, exam: exam ? publicExam(exam) : null, qualificationStatus: sourceSchoolQualificationStatus(db, setting.examId, school.id) };
+ }).filter(item => item.exam);
+ return sendJson(response, 200, { ok: true, school, exams });
+ }
+ const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/);
+ if (qualificationMatch && request.method === 'PUT') {
+ if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
+ const setting = admissionSetting(db, qualificationMatch[1]);
+ const profile = db.candidateProfiles.find(item => item.userId === qualificationMatch[2] && item.schoolId === user.schoolId && item.profileCompleted);
+ const account = db.users.find(item => item.id === profile?.userId && item.role === 'candidate' && item.active);
+ if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
+ if (!profile || !account) return sendError(response, 404, '本校有效考生不存在');
+ const body = await readJson(request);
+ if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择有或无指标分配资格');
+ const now = nowIso();
+ const existing = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === profile.userId);
+ const qualification = existing || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
+ Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
+ const nextRecords = [...db.admissionRecords.filter(item => item.id !== qualification.id), qualification];
+ const nextDb = { ...db, admissionRecords: nextRecords };
+ const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
+ const records = [qualification];
+ if (status.complete) {
+ const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
+ const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
+ Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
+ records.push(publication);
+ }
+ await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
+ return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
+ }
+
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
@@ -184,7 +222,7 @@ export function createAdminRoutes(context) {
const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft');
setting.status = status;
setting.updatedAt = now;
- setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
+ setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), maxSubmissions: Math.min(50, Math.max(1, Math.trunc(Number(body.maxSubmissions || 3)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
return sendJson(response, 200, { ok: true, setting });
@@ -241,9 +279,14 @@ export function createAdminRoutes(context) {
const now = nowIso();
const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now }));
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;
- await database.saveAdmissionRecords([setting, ...admitted, ...notifications], logAction(db, user, '结束录取并发布结果', `${setting.examId} · ${admitted.length} 人`));
- return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows({ ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }, setting.examId) });
+ 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: { 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 body = await readJson(request);
const now = nowIso();
diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs
index 6ca230b..972c94e 100644
--- a/src/routes/candidate.routes.mjs
+++ b/src/routes/candidate.routes.mjs
@@ -1,5 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
-import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
+import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
export function createCandidateRoutes(context) {
@@ -162,16 +162,29 @@ export function createCandidateRoutes(context) {
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 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 {
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
- categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category))
+ categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
+ const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
+ const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
+ const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
+ const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length;
+ const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed);
+ const generalRemaining = Math.max(0, generalQuota - generalUsed);
+ const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean);
+ return { ...category, generalRemaining, indicatorRemaining, preferenceTypes };
+ }).filter(category => category.preferenceTypes.length)
};
}).filter(plan => plan.categories.length);
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
- return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile) };
+ const submissionCount = Number(preference?.payload?.submissionCount || 0);
+ const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
+ return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
}).filter(item => item.exam);
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
@@ -187,17 +200,37 @@ export function createCandidateRoutes(context) {
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
const body = await readJson(request);
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
- const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40) }));
- if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
- if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同一学校和招生类别不能重复填报');
- const plans = approvedPlans(db, setting.examId);
- if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode && candidateEligibleForCategory(profile, category))))) return sendError(response, 400, '志愿中包含未审核通过或与本人资格不符的招生类别');
const round = Number(setting.payload.round || 1);
+ const currentPreference = activePreference(db, setting.examId, user.id, round);
+ const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3));
+ const submissionCount = Number(currentPreference?.payload?.submissionCount || 0);
+ if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`);
+ const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' }));
+ if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
+ const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator');
+ const generalChoices = choices.filter(item => item.preferenceType === 'general');
+ if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
+ if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
+ if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
+ const plans = approvedPlans(db, setting.examId);
+ const indicator = indicatorQualification(db, setting.examId, user.id);
+ const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
+ if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
+ const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn');
+ if (choice.preferenceType === 'indicator') {
+ const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
+ const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
+ return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used;
+ }
+ const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
+ return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length;
+ })));
+ if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
const nowValue = nowIso();
- const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
- Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue } });
+ const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
+ Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
await database.saveAdmissionRecord(preference);
- return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' });
+ return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
}
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
if (request.method === 'POST' && scoreAppealMatch) {
diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs
index 21183f2..0424018 100644
--- a/src/routes/public.routes.mjs
+++ b/src/routes/public.routes.mjs
@@ -1,5 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
-import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs';
+import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
export function createPublicRoutes(context) {
const {
@@ -58,8 +58,20 @@ export function createPublicRoutes(context) {
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 exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
- const admissionAnnouncements = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', completedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }));
- 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, admissionAnnouncements, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
+ return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
+ });
+ return sendJson(response, 200, payload);
+ }
+ if (pathname === '/api/public/announcements') {
+ const payload = await cache.remember('public', 'admission-announcements', async () => {
+ const db = await readDb();
+ const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
+ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
+ rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
+ })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
+ const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
+ const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
+ return { ok: true, qualifications, admissions, cutoffs };
});
return sendJson(response, 200, payload);
}
diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs
index da2dbb1..18d8359 100644
--- a/src/services/volunteer-admission.mjs
+++ b/src/services/volunteer-admission.mjs
@@ -20,6 +20,33 @@ export function activePreference(db, examId, userId, round) {
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
}
+export function indicatorQualification(db, examId, userId) {
+ return admissionRecords(db, 'indicator_qualification', examId).find(item => item.userId === userId) || null;
+}
+
+export function sourceSchoolQualificationStatus(db, examId, schoolId) {
+ const profiles = db.candidateProfiles.filter(profile => profile.schoolId === schoolId && profile.profileCompleted && db.users.some(user => user.id === profile.userId && user.role === 'candidate' && user.active));
+ const qualifications = admissionRecords(db, 'indicator_qualification', examId).filter(item => item.schoolId === schoolId && item.status === 'confirmed');
+ const byUser = new Map(qualifications.map(item => [item.userId, item]));
+ const rows = profiles.map(profile => {
+ const account = db.users.find(item => item.id === profile.userId) || {};
+ const specialty = resolveProfileSpecialty(profile);
+ const qualification = byUser.get(profile.userId) || null;
+ return {
+ userId: profile.userId,
+ registrationNumber: account.candidateNumber || '',
+ name: profile.name || account.displayName || '',
+ eligible: qualification?.payload?.eligible === true,
+ confirmed: Boolean(qualification),
+ confirmedAt: qualification?.payload?.confirmedAt || qualification?.updatedAt || '',
+ specialtyCategory: specialty.category,
+ specialtyType: specialty.type,
+ specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生'
+ };
+ }).sort((left, right) => left.registrationNumber.localeCompare(right.registrationNumber));
+ return { total: rows.length, confirmed: rows.filter(item => item.confirmed).length, complete: rows.length > 0 && rows.every(item => item.confirmed), rows };
+}
+
export function approvedPlans(db, examId) {
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
}
@@ -46,6 +73,33 @@ export function publicAdmissionRows(db, examId) {
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
}
+export function admissionCutoffRows(db, examId) {
+ const groups = new Map();
+ for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
+ const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
+ const row = groups.get(key) || {
+ schoolId: placement.schoolId,
+ schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '',
+ categoryCode: placement.payload?.categoryCode || '',
+ categoryName: placement.payload?.categoryName || '',
+ admittedCount: 0,
+ planQuota: 0,
+ highestScore: null,
+ cutoffScore: null
+ };
+ const score = Number(placement.payload?.totalScore || 0);
+ row.admittedCount += 1;
+ row.highestScore = row.highestScore == null ? score : Math.max(row.highestScore, score);
+ row.cutoffScore = row.cutoffScore == null ? score : Math.min(row.cutoffScore, score);
+ groups.set(key, row);
+ }
+ for (const plan of approvedPlans(db, examId)) for (const category of plan.payload?.categories || []) {
+ const row = groups.get(categoryKey(plan.schoolId, category.code));
+ if (row) row.planQuota = Number(category.quota || 0);
+ }
+ return [...groups.values()].sort((left, right) => left.schoolName.localeCompare(right.schoolName) || left.categoryName.localeCompare(right.categoryName));
+}
+
function categoryKey(schoolId, code) {
return `${schoolId}|${code}`;
}
@@ -89,16 +143,18 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
const key = categoryKey(choice.schoolId, choice.categoryCode);
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
- const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
let quotaBucket = null;
- if (allocation) {
+ if (choice.preferenceType === 'indicator') {
+ const qualification = indicatorQualification(db, examId, candidate.preference.userId);
+ const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
+ if (!qualification?.payload?.eligible || !allocation) continue;
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
quotaBucket = `indicator:${candidate.profile.schoolId}`;
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
}
- }
- if (!quotaBucket) {
+ if (!quotaBucket) continue;
+ } else {
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
quotaBucket = 'general';
@@ -125,4 +181,4 @@ export function remainingPlanQuota(db, plan) {
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
});
}
-import { candidateEligibleForCategory, resolveProfileSpecialty } from '../data/specialty-types.mjs';
+import { candidateEligibleForCategory, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
diff --git a/styles.css b/styles.css
index b44d729..bd10ebc 100644
--- a/styles.css
+++ b/styles.css
@@ -622,3 +622,21 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.result-panel .result-summary.qualified > span:last-child strong,.result-panel .result-summary.unqualified > span:last-child strong { color:var(--navy); }.result-panel .result-summary.qualified > span:nth-child(3) strong { color:#237358; }.result-panel .result-summary.unqualified > span:nth-child(3) strong { color:#a24f43; }
@media (max-width:1000px) { .admission-category-fields { grid-template-columns:1fr 1fr; }.admission-category-fields > label:first-child { grid-column:1/-1; }.feature-score-console,.admission-export-bar { grid-template-columns:1fr; }.school-type-summary { grid-template-columns:repeat(2,1fr); } }
@media (max-width:620px) { .admission-category-fields,.specialty-plan-fields,.indicator-allocation-row,.preference-choice-row,.school-role-selector > div,.feature-score-console form,.school-type-summary { grid-template-columns:1fr; }.admission-category-fields > label:first-child { grid-column:auto; }.preference-choice-list .preference-choice-row > b { justify-self:start; }.admission-builder-head,.indicator-allocation-head { align-items:flex-start; flex-direction:column; }.result-panel .result-summary { grid-template-columns:1fr; } }
+
+.qualification-ledger-intro { margin-bottom:22px; padding:30px 34px; border-radius:22px; color:#fff; background:linear-gradient(125deg,#17375f,#245783 68%,#287486); box-shadow:0 18px 40px rgba(23,55,95,.18); }
+.qualification-ledger-intro span,.announcement-hero .overline { color:#a9d9df; font:700 12px/1.4 Consolas,monospace; letter-spacing:.16em; }
+.qualification-ledger-intro h2 { margin:8px 0; font:700 28px/1.2 STKaiti,KaiTi,serif; }.qualification-ledger-intro p { max-width:780px; margin:0; color:rgba(255,255,255,.78); }
+.qualification-ledger { margin-bottom:22px; overflow:hidden; }.qualification-ledger > header { display:flex; align-items:center; justify-content:space-between; padding:22px 24px 14px; }.qualification-ledger > header span { color:#287486; font:700 12px Consolas,monospace; }.qualification-ledger > header h2 { margin:5px 0 0; }
+.qualification-ledger > header > strong { padding:8px 13px; border-radius:999px; color:#9a6818; background:#fff6df; }.qualification-ledger > header > strong.complete { color:#17665b; background:#e5f5f1; }
+.qualification-progress { height:4px; margin:0 24px; overflow:hidden; border-radius:99px; background:#e6ebf1; }.qualification-progress i { display:block; height:100%; background:linear-gradient(90deg,#287486,#5ea8a0); }
+.qualification-publication-state { margin:14px 24px; padding:10px 13px; border-left:3px solid #287486; color:#17665b; background:#eef8f7; }.qualification-publication-state.pending { border-color:#c69037; color:#76551d; background:#fff8e9; }.qualification-ledger select { min-width:190px; }
+.preference-choice-row.indicator { border-color:#d5c38f; background:#fffaf0; }.preference-choice-row.indicator > b { color:#8a641d; }
+
+.disclosure-entry { display:flex; align-items:center; justify-content:space-between; gap:32px; padding:34px 40px; border-radius:24px; color:#fff; background:#17375f; }.disclosure-entry h2 { margin:5px 0 10px; font:700 32px/1.15 STKaiti,KaiTi,serif; }.disclosure-entry p:last-child { margin:0; color:rgba(255,255,255,.72); }
+.announcement-page { padding-bottom:70px; background:#f4f7fa; }.announcement-hero { min-height:360px; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:60px; padding:80px max(5vw,24px) 55px; color:#fff; background:radial-gradient(circle at 75% 15%,rgba(73,151,154,.38),transparent 32%),linear-gradient(132deg,#102a49,#17375f 55%,#245783); }
+.announcement-hero h1 { margin:12px 0 18px; font:700 clamp(42px,6vw,74px)/.95 STKaiti,KaiTi,serif; letter-spacing:-.03em; }.announcement-hero h1 em { color:#9bd0d2; font-style:normal; }.announcement-hero p:last-child { max-width:700px; color:rgba(255,255,255,.75); font-size:16px; line-height:1.8; }
+.announcement-hero dl { display:grid; grid-template-columns:repeat(3,110px); margin:0; border:1px solid rgba(255,255,255,.2); }.announcement-hero dl div { padding:20px; border-right:1px solid rgba(255,255,255,.2); }.announcement-hero dl div:last-child { border:0; }.announcement-hero dt { color:rgba(255,255,255,.6); font-size:12px; }.announcement-hero dd { margin:7px 0 0; font:700 30px Georgia,serif; }
+.announcement-index { position:sticky; top:72px; z-index:5; display:flex; justify-content:center; gap:4px; padding:12px; border-bottom:1px solid #dfe5ec; background:rgba(247,249,252,.94); backdrop-filter:blur(12px); }.announcement-index a { padding:10px 18px; border-radius:999px; color:#17375f; font-weight:700; text-decoration:none; }.announcement-index a:hover { color:#287486; background:#e3eef2; }
+.announcement-register { scroll-margin-top:130px; }.announcement-sheet { margin-top:20px; overflow:hidden; }.announcement-sheet > header { display:flex; align-items:center; justify-content:space-between; padding:22px 25px; border-bottom:1px solid #e6ebf1; background:linear-gradient(90deg,#fff,#f4f8fa); }.announcement-sheet > header span { color:#637386; font-size:13px; }.announcement-sheet > header h3 { margin:5px 0 0; font-size:20px; }.announcement-sheet > header > strong { color:#17375f; }
+.qualification-result { display:inline-flex; min-width:38px; justify-content:center; padding:4px 9px; border-radius:99px; color:#7b5b24; background:#fff5db; font-weight:700; }.qualification-result.eligible { color:#17665b; background:#e4f4ef; }.cutoff-score { color:#a75c18; font:700 20px Georgia,serif; }
+@media (max-width:760px) { .disclosure-entry,.announcement-hero { display:block; }.disclosure-entry button { margin-top:22px; }.announcement-hero dl { margin-top:28px; grid-template-columns:repeat(3,1fr); }.announcement-hero dl div { padding:14px 10px; }.announcement-index { justify-content:flex-start; overflow-x:auto; }.announcement-index a { white-space:nowrap; } }
diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs
index 4fad4e8..2d0c176 100644
--- a/tests/admission.test.mjs
+++ b/tests/admission.test.mjs
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict';
-import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs';
+import { admissionCutoffRows, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs';
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
const now = '2026-07-21T08:00:00.000Z';
let sequence = 0;
const db = {
users: [
- { id: 'u-high', candidateNumber: '20260001', displayName: '高分考生' },
- { id: 'u-low', candidateNumber: '20260002', displayName: '次高考生' },
- { id: 'u-sport', candidateNumber: '20260003', displayName: '特长考生' }
+ { id: 'u-high', role: 'candidate', active: true, candidateNumber: '20260001', displayName: '高分考生' },
+ { id: 'u-low', role: 'candidate', active: true, candidateNumber: '20260002', displayName: '次高考生' },
+ { id: 'u-sport', role: 'candidate', active: true, candidateNumber: '20260003', displayName: '特长考生' }
],
candidateProfiles: [
- { userId: 'u-high', name: '高分考生', schoolId: 'source-a', idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
- { userId: 'u-low', name: '次高考生', schoolId: 'source-b', idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
- { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
+ { userId: 'u-high', name: '高分考生', schoolId: 'source-a', profileCompleted: true, idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
+ { userId: 'u-low', name: '次高考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
+ { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
],
schools: [
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
@@ -35,9 +35,11 @@ const db = {
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
{ code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
] } },
- { id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
- { id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
- { id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport' }] } }
+ { id: 'qual-low', kind: 'indicator_qualification', examId: 'exam', userId: 'u-low', schoolId: 'source-b', status: 'confirmed', payload: { eligible: false } },
+ { id: 'qual-sport', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
+ { id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
+ { id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
+ { id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
]
};
@@ -62,5 +64,10 @@ assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名');
assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩');
assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校');
assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
+const cutoffs = admissionCutoffRows(db, 'exam');
+assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
+const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
+assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
+assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
console.log('志愿投档、指标名额与脱敏公示测试通过');
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 5d2d6a7..1ec18ee 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -47,7 +47,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
-assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v19 结构并重建更旧或未完成的开发结构');
+assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19, 20\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v20 结构并重建更旧或未完成的开发结构');
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
@@ -232,7 +232,7 @@ try {
inspector.close();
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
- assert.equal(schemaVersion, 19, '学校类型、特长资格与特征分应使用 v19 数据结构');
+ assert.equal(schemaVersion, 20, '指标资格、资格公示与分数线公告应使用 v20 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
@@ -577,13 +577,29 @@ 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 } })).response.status, 200, '超级管理员应能按考试启用志愿功能');
+ 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 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: [] }
] } });
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
+ assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
+ assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
+ assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
+ const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
+ const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id);
+ assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生');
+ for (const row of qualificationExam.qualificationStatus.rows) {
+ const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${row.userId}`, { method: 'PUT', body: { eligible: row.registrationNumber === candidateNumber } });
+ assert.equal(confirmation.response.status, 200, `生源校应能逐人确认指标分配资格:${confirmation.data?.error || confirmation.data?.message || ''}\n${serverError}`);
+ }
+ const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
+ assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示');
+ const publicQualification = await anonymous.request('/api/public/announcements');
+ const qualificationPublication = publicQualification.data.qualifications.find(item => item.examId === exam.id && item.schoolName === '海州市第一中学');
+ assert.ok(qualificationPublication, '资格全部确认后应自动出现在独立公开公告接口');
+ assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
@@ -895,6 +911,18 @@ try {
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
+ assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
+ const candidateAdmissions = await candidate.request('/api/candidate/admissions');
+ const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
+ assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
+ assert.equal(candidateAdmission.maxSubmissions, 1, '考生页面应显示管理员设置的提交次数上限');
+ const firstPreference = await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [
+ { schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'indicator' },
+ { schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }
+ ] } });
+ assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
+ assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
+ assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
const classResults = await classAdmin.request('/api/admin/results');
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');