已录取且报到的考生:补录入口隐藏,接口再次提交会返回 403。

多学校补录:按同一轮全部获批学校开放,并修复“放弃考生仍占用名额”的计算问题。
志愿锁定显示:保存学校和类别名称快照;旧数据中的 general 等代码也能还原名称。考生端、管理员台账同步修复。
录取结果通知:改为正式签发卡片,展示学校、类别、考试、通知书编号和录取状态,并适配移动端。
This commit is contained in:
2026-07-22 12:00:09 +08:00 Unverified
parent 7430c7bb89
commit c70a97231a
8 changed files with 140 additions and 20 deletions
+1 -1
View File
@@ -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', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPreferenceTable" placeholder="搜索考生、报名号、考试、志愿学校或类别"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="adminPreferenceTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="adminPreferenceTable">清除筛选</button></div></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr data-exam="${h(item.examId)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的志愿记录</td></tr>'}</tbody></table></div>${pagination(preferencePage)}</section>`);
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPreferenceTable" placeholder="搜索考生、报名号、考试、志愿学校或类别"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="adminPreferenceTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="adminPreferenceTable">清除筛选</button></div></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr data-exam="${h(item.examId)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td><div class="admin-preference-choices">${item.choices.map((choice, index) => `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(choice.schoolName || choice.schoolId)}</strong><small>${h([choice.schoolCode, choice.categoryName || choice.categoryCode].filter(Boolean).join(' · '))}</small></i></span>`).join('')}</div></td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的志愿记录</td></tr>'}</tbody></table></div>${pagination(preferencePage)}</section>`);
}
function adminDashboard(data) {
+8 -4
View File
@@ -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 ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const notificationCards = (data.notifications || []).map(notification => {
const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus);
return `<article class="admission-notification ${invalid ? 'invalid' : ''}"><div class="admission-notification-mark"><span>ADMISSION</span><strong>${invalid ? '失' : '录'}</strong></div><div class="admission-notification-copy"><header><div><span>${invalid ? '录取状态已更新' : '录取结果已发布'}</span><h2>${h(notification.payload?.title || '录取结果通知')}</h2></div><time>${formatDate(notification.createdAt, true)}</time></header><p>${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}</p><dl><div><dt>录取学校</dt><dd>${h(notification.schoolName || '招生学校')}</dd></div><div><dt>招生类别</dt><dd>${h(notification.categoryName || '以录取通知书为准')}</dd></div><div><dt>所属考试</dt><dd>${h(notification.examName || '—')}</dd></div>${notification.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(notification.noticeNumber)}</dd></div>` : ''}</dl></div><span class="admission-notification-status">${invalid ? '资格已失效' : '正式录取'}</span></article>`;
}).join('');
return `${notificationCards ? `<section class="admission-notification-stack" aria-label="录取结果通知">${notificationCards}</section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && 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 `<div class="preference-choice-row ${preferenceType}" data-preference-type="${preferenceType}"><b>${preferenceType === 'indicator' ? '指标' : index + 1}</b><label><span>${preferenceType === 'indicator' ? '' : ` ${index + 1} · `}</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}" ${disabled ? 'disabled' : ''}><option value="">${disabled ? '' : ''}</option>${eligiblePlans.map(entry => `<option value="${h(entry.schoolId)}" ${entry.schoolId === choice.schoolId ? 'selected' : ''}>${h(entry.schoolCode)} · ${h(entry.schoolName)}</option>`).join('')}</select></label><label><span></span><select name="choiceCategory" ${plan && !disabled ? '' : 'disabled'}><option value="">${plan ? '' : ''}</option>${categoryOptions.map(category => `<option value="${h(category.code)}" ${category.code === choice.categoryCode ? 'selected' : ''}>${h(category.name)}${category.specialtyCategory ? `${h(specialtyLabel(category.specialtyCategory, category.specialtyType))}` : ''} · ${h(preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining)}</option>`).join('')}</select></label></div>`;
};
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 `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('');
const 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 `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(schoolName)}</strong><small>${h([schoolCode, categoryName].filter(Boolean).join(' · '))}</small></i></span>`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout ${item.supplementEligible === false ? 'warning' : ''}">${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}</div>`}</section>`;
}).join('')}</div>`;
}
+10 -1
View File
@@ -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);
+51 -10
View File
@@ -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 ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
}
+18 -1
View File
@@ -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 });