From c70a97231a32f888e8f0810a4f1cf44fab4ef418 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 12:00:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=BD=95=E5=8F=96=E4=B8=94=E6=8A=A5?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E8=80=83=E7=94=9F=EF=BC=9A=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=9A=90=E8=97=8F=EF=BC=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=8D=E6=AC=A1=E6=8F=90=E4=BA=A4=E4=BC=9A=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20403=E3=80=82=20=E5=A4=9A=E5=AD=A6=E6=A0=A1=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=EF=BC=9A=E6=8C=89=E5=90=8C=E4=B8=80=E8=BD=AE=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E8=8E=B7=E6=89=B9=E5=AD=A6=E6=A0=A1=E5=BC=80=E6=94=BE=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E2=80=9C=E6=94=BE=E5=BC=83=E8=80=83?= =?UTF-8?q?=E7=94=9F=E4=BB=8D=E5=8D=A0=E7=94=A8=E5=90=8D=E9=A2=9D=E2=80=9D?= =?UTF-8?q?=E7=9A=84=E8=AE=A1=E7=AE=97=E9=97=AE=E9=A2=98=E3=80=82=20?= =?UTF-8?q?=E5=BF=97=E6=84=BF=E9=94=81=E5=AE=9A=E6=98=BE=E7=A4=BA=EF=BC=9A?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=AD=A6=E6=A0=A1=E5=92=8C=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=BF=AB=E7=85=A7=EF=BC=9B=E6=97=A7=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=AD=E7=9A=84=20general=20=E7=AD=89=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=B9=9F=E8=83=BD=E8=BF=98=E5=8E=9F=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E3=80=82=E8=80=83=E7=94=9F=E7=AB=AF=E3=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=8F=B0=E8=B4=A6=E5=90=8C=E6=AD=A5=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=82=20=E5=BD=95=E5=8F=96=E7=BB=93=E6=9E=9C=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=EF=BC=9A=E6=94=B9=E4=B8=BA=E6=AD=A3=E5=BC=8F=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E5=8D=A1=E7=89=87=EF=BC=8C=E5=B1=95=E7=A4=BA=E5=AD=A6?= =?UTF-8?q?=E6=A0=A1=E3=80=81=E7=B1=BB=E5=88=AB=E3=80=81=E8=80=83=E8=AF=95?= =?UTF-8?q?=E3=80=81=E9=80=9A=E7=9F=A5=E4=B9=A6=E7=BC=96=E5=8F=B7=E5=92=8C?= =?UTF-8?q?=E5=BD=95=E5=8F=96=E7=8A=B6=E6=80=81=EF=BC=8C=E5=B9=B6=E9=80=82?= =?UTF-8?q?=E9=85=8D=E7=A7=BB=E5=8A=A8=E7=AB=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/admin-views.mjs | 2 +- src/client/candidate-views.mjs | 12 ++++-- src/routes/admin.routes.mjs | 11 ++++- src/routes/candidate.routes.mjs | 61 +++++++++++++++++++++++----- src/services/volunteer-admission.mjs | 19 ++++++++- styles.css | 14 ++++++- tests/admission.test.mjs | 21 +++++++++- tests/system.test.mjs | 20 ++++++++- 8 files changed, 140 insertions(+), 20 deletions(-) diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index c303875..497b5e6 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -84,7 +84,7 @@ export function createAdminViews(context) { const container = app.querySelector('.portal-content'); if (!container || !data.preferences?.length) return; const preferencePage = paged(data.preferences, 'adminPreferenceTable'); - container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
没有符合条件的志愿记录
${pagination(preferencePage)}
`); + container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮
${item.choices.map((choice, index) => `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(choice.schoolName || choice.schoolId)}${h([choice.schoolCode, choice.categoryName || choice.categoryCode].filter(Boolean).join(' · '))}`).join('')}
${formatDate(item.payload.submittedAt, true)}
没有符合条件的志愿记录
${pagination(preferencePage)}
`); } function adminDashboard(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 8817c15..6639cb3 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -201,10 +201,14 @@ export function createCandidateViews(context) { function candidateAdmissions(data) { const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' }; 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 notificationCards = (data.notifications || []).map(notification => { + const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus); + return `
ADMISSION${invalid ? '失' : '录'}
${invalid ? '录取状态已更新' : '录取结果已发布'}

${h(notification.payload?.title || '录取结果通知')}

${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}

录取学校
${h(notification.schoolName || '招生学校')}
招生类别
${h(notification.categoryName || '以录取通知书为准')}
所属考试
${h(notification.examName || '—')}
${notification.noticeNumber ? `
通知书编号
${h(notification.noticeNumber)}
` : ''}
${invalid ? '资格已失效' : '正式录取'}
`; + }).join(''); + return `${notificationCards ? `
${notificationCards}
` : ''}
${data.admissions.map(item => { const choices = item.preference?.payload?.choices || []; const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false; - const placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || ''; + const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校'; const progressSteps = ['filling', 'closed', 'school_review', 'completed']; const progressIndex = item.status === 'supplementary' ? 1 : item.status === 'reporting' ? 3 : Math.max(0, progressSteps.indexOf(item.status)); const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {}; @@ -218,10 +222,10 @@ export function createCandidateViews(context) { 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 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); const schoolCode = choice.schoolCode || plan?.schoolCode || ''; const schoolName = choice.schoolName || plan?.schoolName || choice.schoolId; const categoryName = choice.categoryName || category?.name || choice.categoryCode; return `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(schoolName)}${h([schoolCode, categoryName].filter(Boolean).join(' · '))}`; }).join(''); const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生'; 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.noticeNumber ? `录取通知书编号:${h(item.noticeNumber)}` : ''}${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}
`; + 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.noticeNumber ? `录取通知书编号:${h(item.noticeNumber)}` : ''}${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}
`}
`; }).join('')}
`; } diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 26195fa..1cd9b68 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -298,7 +298,16 @@ export function createAdminRoutes(context) { const preferences = admissionRecords(db, 'preference').map(preference => { const account = userById.get(preference.userId) || {}; const profile = profileByUserId.get(preference.userId) || {}; - return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: schoolById.get(choice.schoolId)?.name || '' })) }; + const plansForExam = admissionRecords(db, 'plan', preference.examId); + return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => { + const school = schoolById.get(choice.schoolId); + const categories = plansForExam.filter(plan => plan.schoolId === choice.schoolId).flatMap(plan => plan.payload?.categories || []); + const category = categories.find(item => item.code === choice.categoryCode) + || (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null) + || (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null) + || (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null); + return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' }; + }) }; }); const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => { const school = schoolById.get(item.schoolId); diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 2412588..a00913a 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, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.mjs'; +import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota, supplementarySchoolIds } from '../services/volunteer-admission.mjs'; import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs'; import QRCode from 'qrcode'; import { systemNotificationItems } from '../services/system-notifications.mjs'; @@ -190,12 +190,38 @@ 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 preferenceView = preference ? { + ...preference, + payload: { + ...preference.payload, + choices: (preference.payload?.choices || []).map(choice => { + const school = db.schools.find(item => item.id === choice.schoolId); + const categories = admissionRecords(db, 'plan', setting.examId) + .filter(item => item.schoolId === choice.schoolId) + .flatMap(item => item.payload?.categories || []); + const category = categories.find(item => item.code === choice.categoryCode) + || (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null) + || (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null) + || (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null); + return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' }; + }) + } + } : null; 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 supplementEligible = !admissionRecords(db, 'placement', setting.examId).some(item => item.userId === user.id && item.status === 'forfeited'); - const plans = (supplementEligible ? approvedPlans(db, setting.examId) : []).map(plan => { + const blockingPlacement = setting.status === 'supplementary' + ? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status)) + : null; + const supplementEligible = !blockingPlacement; + const supplementIneligibilityReason = blockingPlacement?.status === 'forfeited' + ? '因本轮未按规定完成报到,不能再次参加补录。' + : blockingPlacement + ? '你已被录取,本轮补录无需且不能再次填报。' + : ''; + const supplementarySchools = supplementarySchoolIds(db, setting); + const plans = (supplementEligible ? approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)) : []).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'); + const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status)); return { id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '', categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => { @@ -218,9 +244,14 @@ export function createCandidateRoutes(context) { const noticeTemplate = templateRecord?.payload?.template || null; const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : ''; const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : ''; - return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions }; + return { ...setting, exam: exam ? publicExam(exam) : null, preference: preferenceView, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, supplementIneligibilityReason, 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); + const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id).map(item => { + const placement = admissionRecords(db, 'placement', item.examId).find(entry => entry.id === item.payload?.placementId); + const school = db.schools.find(entry => entry.id === (placement?.schoolId || item.schoolId)); + const exam = db.exams.find(entry => entry.id === item.examId); + return { ...item, examName: exam?.name || '', schoolName: school?.name || '', schoolCode: school?.code || '', categoryName: placement?.payload?.categoryName || '', noticeNumber: placement?.payload?.noticeNumber || '', placementStatus: placement?.status || '' }; + }).sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt)); return sendJson(response, 200, { ok: true, admissions: settings, notifications }); } const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/); @@ -228,7 +259,11 @@ export function createCandidateRoutes(context) { const setting = admissionSetting(db, preferenceMatch[1]); if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报'); if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段'); - if (setting.status === 'supplementary' && admissionRecords(db, 'placement', setting.examId).some(item => item.userId === user.id && item.status === 'forfeited')) return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录'); + if (setting.status === 'supplementary') { + const blockingPlacement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status)); + if (blockingPlacement?.status === 'forfeited') return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录'); + if (blockingPlacement) return sendError(response, 403, '你已被录取,本轮补录不能再次填报'); + } const now = Date.now(); if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始'); if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止'); @@ -247,11 +282,12 @@ export function createCandidateRoutes(context) { 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 supplementarySchools = supplementarySchoolIds(db, setting); + const plans = approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)); 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'); + const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status)); 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; @@ -263,7 +299,12 @@ export function createCandidateRoutes(context) { if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别'); const nowValue = nowIso(); 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 } }); + const storedChoices = choices.map(choice => { + const school = db.schools.find(item => item.id === choice.schoolId); + const category = plans.find(plan => plan.schoolId === choice.schoolId)?.payload?.categories?.find(item => item.code === choice.categoryCode); + return { ...choice, schoolCode: school?.code || '', schoolName: school?.name || '', categoryName: category?.name || '' }; + }); + Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices: storedChoices, submittedAt: nowValue, submissionCount: submissionCount + 1 } }); await database.saveAdmissionRecord(preference); return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' }); } diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs index 2f7b50a..4591266 100644 --- a/src/services/volunteer-admission.mjs +++ b/src/services/volunteer-admission.mjs @@ -60,6 +60,22 @@ export function approvedPlans(db, examId) { return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved'); } +export function supplementarySchoolIds(db, setting) { + if (setting?.status !== 'supplementary') return null; + const sourceRound = Math.max(1, Number(setting.payload?.round || 1) - 1); + const schoolIds = admissionRecords(db, 'notification', setting.examId) + .filter(item => item.userId == null + && item.status === 'approved' + && item.payload?.type === 'admission_reporting' + && Number(item.payload?.round || 1) === sourceRound + && item.payload?.supplementDecision === 'supplement') + .map(item => item.schoolId) + .filter(Boolean); + // Older data could enter the supplementary phase without reporting decisions. + // Preserve that legacy behavior, while new rounds are restricted to approved schools. + return schoolIds.length ? new Set(schoolIds) : null; +} + export function planSummary(plan) { const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : []; return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) }; @@ -233,7 +249,8 @@ function categoryKey(schoolId, code) { export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { const examId = setting.examId; const round = Number(setting.payload?.round || 1); - const plans = approvedPlans(db, examId); + const supplementarySchools = supplementarySchoolIds(db, setting); + const plans = approvedPlans(db, examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)); const categories = new Map(); for (const plan of plans) for (const category of plan.payload?.categories || []) { categories.set(categoryKey(plan.schoolId, category.code), { plan, category }); diff --git a/styles.css b/styles.css index 8132cd2..c6374b4 100644 --- a/styles.css +++ b/styles.css @@ -638,7 +638,19 @@ button:disabled { cursor: not-allowed; opacity: .5; } .admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; } .admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; } .admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; } -.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; } +.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences > strong { margin-bottom:2px; color:#27384e; }.locked-preferences > span,.admin-preference-choices > span { display:flex; align-items:center; gap:12px; padding:11px 13px; border:1px solid #e2e8ee; border-radius:9px; background:#f7f9fb; }.locked-preferences b,.admin-preference-choices b { flex:0 0 35px; color:#287486; font-size:11px; text-align:center; }.locked-preferences i,.admin-preference-choices i { display:grid; gap:3px; font-style:normal; }.locked-preferences small,.admin-preference-choices small { color:#7d8998; }.admin-preference-choices { display:grid; gap:6px; min-width:300px; } +.read-only-callout.warning { border-left:3px solid #b27b2d; color:#75531f; background:#fff8e9; } + +/* 录取结果通知沿用正式通知书的“签发栏”语汇,以状态与校名为阅读主线。 */ +.admission-notification-stack { display:grid; gap:12px; margin-bottom:18px; } +.admission-notification { position:relative; display:grid; grid-template-columns:92px minmax(0,1fr) auto; overflow:hidden; border:1px solid #cad9df; border-radius:14px; color:#24354b; background:linear-gradient(105deg,#f5fafb 0 92px,#fff 92px); box-shadow:0 14px 34px rgba(23,55,95,.08); } +.admission-notification::after { content:""; position:absolute; inset:0 0 auto 92px; height:4px; background:linear-gradient(90deg,#287486,#d2ad62 70%,transparent); } +.admission-notification-mark { display:grid; align-content:space-between; justify-items:center; padding:20px 12px; color:#dcebed; background:#17375f; }.admission-notification-mark span { writing-mode:vertical-rl; color:#9fc8cd; font:700 10px Consolas,monospace; letter-spacing:.18em; }.admission-notification-mark strong { display:grid; place-items:center; width:34px; height:34px; border:1px solid rgba(255,255,255,.35); border-radius:50%; font:400 18px Georgia,serif; } +.admission-notification-copy { min-width:0; padding:23px 26px 20px; }.admission-notification-copy header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; }.admission-notification-copy header span { color:#287486; font-size:11px; font-weight:800; letter-spacing:.08em; }.admission-notification-copy h2 { margin:5px 0 0; font:700 24px/1.25 STKaiti,KaiTi,serif; }.admission-notification-copy time { color:#8793a2; font-size:11px; white-space:nowrap; }.admission-notification-copy > p { margin:12px 0 17px; color:#566478; } +.admission-notification-copy dl { display:flex; flex-wrap:wrap; gap:0; margin:0; border-top:1px solid #e2e8ed; }.admission-notification-copy dl div { min-width:150px; padding:12px 24px 0 0; }.admission-notification-copy dt { color:#9099a7; font-size:10px; }.admission-notification-copy dd { margin:4px 0 0; font-weight:700; } +.admission-notification-status { align-self:start; margin:22px 22px 0 0; padding:7px 11px; border:1px solid #a6cfc7; border-radius:999px; color:#17665b; background:#e7f5f1; font-size:11px; font-weight:800; white-space:nowrap; } +.admission-notification.invalid { border-color:#dfcecb; }.admission-notification.invalid::after { background:linear-gradient(90deg,#9c5c55,#d2ad62 70%,transparent); }.admission-notification.invalid .admission-notification-mark { background:#6f3f43; }.admission-notification.invalid .admission-notification-status { border-color:#e1c6c1; color:#8a4941; background:#faece9; } +@media (max-width:700px) { .admission-notification { grid-template-columns:58px minmax(0,1fr); }.admission-notification::after { left:58px; }.admission-notification-mark { padding:17px 8px; }.admission-notification-copy { padding:20px 16px; }.admission-notification-copy header { display:grid; gap:6px; }.admission-notification-status { grid-column:2; grid-row:2; justify-self:start; margin:0 16px 16px; }.admission-notification-copy dl { display:grid; grid-template-columns:1fr 1fr; }.admission-notification-copy dl div { min-width:0; } } .placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; } .placement-review-ledger { margin-bottom:16px; overflow:hidden; }.placement-review-ledger .data-toolbar { flex-wrap:wrap; }.placement-review-ledger table { min-width:1250px; }.placement-review-ledger td > small,.placement-review-ledger td > strong { display:block; margin-top:3px; }.placement-status-pills,.account-status-pills { margin:0 24px 12px; }.placement-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:0 24px 16px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }.placement-bulk-toolbar > label,.placement-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.placement-bulk-toolbar input { width:17px; height:17px; }.placement-bulk-toolbar strong { margin-right:5px; color:#287486; }.placement-bulk-toolbar button { min-height:40px; } @media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } } diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs index 1d94576..e8bfff5 100644 --- a/tests/admission.test.mjs +++ b/tests/admission.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs'; +import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus, supplementarySchoolIds } from '../src/services/volunteer-admission.mjs'; import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs'; import { systemNotificationItems } from '../src/services/system-notifications.mjs'; @@ -75,6 +75,25 @@ db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' } const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b')); assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计'); assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计'); + +const supplementDb = structuredClone(db); +const supplementSetting = { id: 'setting-supplement', kind: 'setting', examId: 'exam', status: 'supplementary', payload: { round: 2 } }; +supplementDb.admissionRecords.push( + supplementSetting, + { id: 'report-a', kind: 'notification', examId: 'exam', schoolId: 'target-a', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } }, + { id: 'report-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } } +); +for (const placement of supplementDb.admissionRecords.filter(item => item.kind === 'placement' && item.payload?.categoryCode === 'general')) placement.status = 'forfeited'; +for (const [suffix, schoolId, score] of [['a', 'target-a', 230], ['b', 'target-b', 225]]) { + supplementDb.users.push({ id: `u-supp-${suffix}`, role: 'candidate', active: true, candidateNumber: `2026010${suffix === 'a' ? 1 : 2}`, displayName: `补录考生${suffix.toUpperCase()}` }); + supplementDb.candidateProfiles.push({ userId: `u-supp-${suffix}`, name: `补录考生${suffix.toUpperCase()}`, schoolId: 'source-a', profileCompleted: true, specialtyTypes: [] }); + supplementDb.registrations.push({ id: `r-supp-${suffix}`, examId: 'exam', userId: `u-supp-${suffix}`, status: 'approved', subjectIds: ['cn'] }); + supplementDb.results.push({ registrationId: `r-supp-${suffix}`, subjectId: 'cn', score, published: true }); + supplementDb.admissionRecords.push({ id: `pref-supp-${suffix}`, kind: 'preference', examId: 'exam', userId: `u-supp-${suffix}`, status: 'submitted', payload: { round: 2, choices: [{ schoolId, categoryCode: 'general', preferenceType: 'general' }] } }); +} +assert.deepEqual([...supplementarySchoolIds(supplementDb, supplementSetting)].sort(), ['target-a', 'target-b'], '同轮多所学校获批补录时应完整保留学校集合'); +const supplementPlacements = buildVolunteerPlacements(supplementDb, supplementSetting, { uid: prefix => `${prefix}-supp-${++sequence}`, nowIso: () => now }); +assert.deepEqual(supplementPlacements.map(item => item.schoolId).sort(), ['target-a', 'target-b'], '放弃考生不得继续占用缺额,两所获批学校都应进入补录投档'); const publicRows = publicAdmissionRows(db, 'exam'); const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001'); assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 786606a..88c89a2 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -645,6 +645,9 @@ try { const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3); assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生'); + const reportedCandidate = createClient(); + const reportedCandidateLogin = await reportedCandidate.request('/api/auth/login', { method: 'POST', body: { username: placementCandidates[0].registrationNumber, password: '12345678' } }); + assert.equal(reportedCandidateLogin.response.status, 200, '报到补录边界测试应能登录正式录取考生账号'); const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`); const placementCreatedAt = new Date().toISOString(); const placementWriter = new DatabaseSync(testDb); @@ -712,7 +715,8 @@ try { assert.equal(scannedReporting.response.status, 200, '确认页点击暂存后应能保存扫码报到结果'); assert.equal(scannedReporting.data.row.status, 'reported', '确认页默认选择应能暂存为已报到而不是直接提交'); - const secondRow = reportingBatch.rows[1]; + const secondRow = reportingBatch.rows.find(row => row.noticeNumber !== finalizedPlacement.payload.noticeNumber); + assert.ok(secondRow, '报到测试应找到另一名尚未处理的正式录取考生'); const importReportingFile = Buffer.from(await buildWorkbook('admission_reporting', [{ noticeNumber: secondRow.noticeNumber, candidateNumber: secondRow.candidateNumber, name: secondRow.name, examCode: exam.code, schoolCode: admissionOnlySchool.data.school.code, categoryName: secondRow.categoryName, reportingStatusCode: 'N', reportingNote: '逾期未报到' }])); const importedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile }); assert.equal(importedReporting.response.status, 200, '招生学校应能导入修改后的报到 Excel 并暂存'); @@ -731,6 +735,12 @@ try { const approvedReporting = await admin.request(`/api/admin/admission-reporting/${pendingReportingApproval.id}`, { method: 'PATCH', body: { approved: true, approvalNote: '同意按缺额补录', preferenceEnd: supplementEnd } }); assert.equal(approvedReporting.response.status, 200, '超级管理员应能批准补录并自动公开报到情况'); assert.equal(approvedReporting.data.phase, 'supplementary', '全部学校审批完成且存在补录申请时应自动开启下一轮补录'); + const reportedCandidateSupplementView = await reportedCandidate.request('/api/candidate/admissions'); + const reportedCandidateAdmission = reportedCandidateSupplementView.data.admissions.find(item => item.examId === exam.id); + assert.equal(reportedCandidateAdmission.supplementEligible, false, '已经正式录取并报到的考生不得再次进入补录填报'); + assert.match(reportedCandidateAdmission.supplementIneligibilityReason, /已被录取/, '考生页面应明确说明不能重复参加补录的原因'); + const repeatedSupplementPreference = await reportedCandidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: admissionOnlySchool.data.school.id, categoryCode: 'general', preferenceType: 'general' }] } }); + assert.equal(repeatedSupplementPreference.response.status, 403, '服务端必须拒绝已录取且已报到考生再次提交补录志愿'); const reportingAnnouncements = await anonymous.request('/api/public/announcements'); const reportingPublication = reportingAnnouncements.data.reports.find(item => item.id === pendingReportingApproval.id); assert.ok(reportingPublication?.title.includes('补录说明'), '批准补录后报到公示标题应自动包含补录说明'); @@ -1106,6 +1116,14 @@ try { ] } }); assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿'); assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定'); + const lockedPreferenceView = await candidate.request('/api/candidate/admissions'); + const lockedChoice = lockedPreferenceView.data.admissions.find(item => item.examId === exam.id).preference.payload.choices[0]; + assert.equal(lockedChoice.schoolName, '海州市招生实验学校', '考生志愿锁定后应稳定返回学校名称,而不是只返回代码'); + assert.equal(lockedChoice.categoryName, '普通生', '考生志愿锁定后应稳定返回招生类别名称'); + const adminPreferenceView = await admin.request('/api/admin/admissions'); + const adminLockedChoice = adminPreferenceView.data.preferences.find(item => item.id === firstPreference.data.preference.id).choices[0]; + assert.equal(adminLockedChoice.schoolName, '海州市招生实验学校', '管理员志愿台账应显示完整学校名称'); + assert.equal(adminLockedChoice.categoryName, '普通生', '管理员志愿台账应显示招生类别名称而不是类别代码'); 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?examId=${encodeURIComponent(exam.id)}`); assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');