From 6be1ff227aeb3511b77ca6fdf1c88bd6f90dbc57 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 14:37:06 +0800 Subject: [PATCH] Add indicator qualification and admissions announcements --- README.md | 13 +++--- app.js | 22 +++++++--- database.mjs | 2 +- src/client/admin-views.mjs | 10 ++++- src/client/candidate-views.mjs | 26 ++++++----- src/client/public-views.mjs | 20 +++++++-- src/client/state.mjs | 3 +- src/database/mysql-adapter.mjs | 7 ++- src/database/schema.mjs | 4 +- src/database/sqlite-adapter.mjs | 24 ++++++++++ src/routes/admin.routes.mjs | 53 +++++++++++++++++++--- src/routes/candidate.routes.mjs | 55 ++++++++++++++++++----- src/routes/public.routes.mjs | 18 ++++++-- src/services/volunteer-admission.mjs | 66 +++++++++++++++++++++++++--- styles.css | 18 ++++++++ tests/admission.test.mjs | 27 +++++++----- tests/system.test.mjs | 34 ++++++++++++-- 17 files changed, 332 insertions(+), 70 deletions(-) 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 = `

代招生校上传计划

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

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

招生计划审核

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

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
`; @@ -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 `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}
${status.rows.map(row => ``).join('') || ''}
报名号 / 姓名特长类型指标分配资格确认时间保存
${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}
本校暂无资料已完善的在册考生
`; }).join('')}`; + } + function adminNotices(notices) { return `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; } 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 `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}资格 ${h(qualification)}${h(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 ? `
按学校代码填写志愿先匹配招生学校,再选择该校对本人开放的招生类别;仅你本人可保存和修改。
最多 ${h(item.payload.maxChoices)} 个
${choiceRows}
` : choices.length ? `
已锁定志愿顺序${lockedRows}
` : '
当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}
`; + const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格'; + return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

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

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

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

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
- ${admissionAnnouncements.length ? `

ADMISSION DISCLOSURE

录取结果公示

报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。

${admissionAnnouncements.map(announcement => `
${formatDate(announcement.completedAt)}

${h(announcement.examName)}

${announcement.rows.length} 人录取
${announcement.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别身份核验
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}${h(row.idNumberMasked)}
`).join('')}
` : ''} +

ADMISSION DISCLOSURE

招生录取公开卷宗

指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['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 => `
${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}

${h(item.examName)}

${h(item.rows.length)} 人
${item.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`).join('') || '
暂无已完成全校确认的资格公示
'}
+

ADMISSION RESULTS

最终录取名单

录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。

${(data.admissions || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 人录取
${item.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`).join('') || '
暂无已完成的录取公告
'}
+

ADMISSION CUTOFFS

录取分数线统计

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

${(data.cutoffs || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 条分数线
${item.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`).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, '班级管理员不得录入或发布成绩');