Add indicator qualification and admissions announcements

This commit is contained in:
2026-07-21 14:37:06 +08:00 Unverified
parent 5e0bee60e2
commit 6be1ff227a
17 changed files with 332 additions and 70 deletions
+8 -2
View File
@@ -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 = `<section class="panel admission-settings-panel"><div class="panel-title"><div><h2>考试志愿设置</h2><p>成绩发布后按场次开放,并持续向考生展示录取进度。</p></div></div><form data-form="admission-setting"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selected?.examId ? 'selected' : ''}>${h(exam.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>填报开始</span><input name="preferenceStart" type="datetime-local" value="${h(selected?.payload?.preferenceStart?.slice(0,16))}"></label><label><span>填报结束</span><input name="preferenceEnd" type="datetime-local" value="${h(selected?.payload?.preferenceEnd?.slice(0,16))}"></label></div><div class="field-row"><label><span>当前阶段</span><select name="status">${Object.entries(phaseLabels).map(([value,label]) => `<option value="${value}" ${value === (selected?.status || 'draft') ? 'selected' : ''}>${label}</option>`).join('')}</select></label><label><span>最多志愿数</span><input name="maxChoices" type="number" min="1" max="20" value="${h(selected?.payload?.maxChoices || 5)}"></label></div><label><span>考生进度说明</span><input name="progress" value="${h(selected?.payload?.progress || '')}" placeholder="例如:招生学校正在审核投档名单"></label><label class="agreement"><input type="checkbox" name="enabled" ${selected?.payload?.enabled ? 'checked' : ''}><span>为本场考试启用志愿填报</span></label><label class="agreement"><input type="checkbox" name="autoPublish" ${selected?.payload?.autoPublish === false ? '' : 'checked'}><span>录取结束后自动脱敏公示</span></label><button class="solid-button" type="submit">保存志愿设置</button></form>${selected ? `<div class="admission-control-actions"><button class="solid-button" data-action="admission-match" data-exam-id="${h(selected.examId)}">按规则投档</button><button class="ghost-button" data-action="admission-finalize" data-exam-id="${h(selected.examId)}">结束录取并通知</button><button class="ghost-button" data-action="admission-supplementary" data-exam-id="${h(selected.examId)}">开启补录</button></div>` : ''}</section>`;
const settings = `<section class="panel admission-settings-panel"><div class="panel-title"><div><h2>考试志愿设置</h2><p>成绩发布后按场次开放,并持续向考生展示录取进度。</p></div></div><form data-form="admission-setting"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selected?.examId ? 'selected' : ''}>${h(exam.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>填报开始</span><input name="preferenceStart" type="datetime-local" value="${h(selected?.payload?.preferenceStart?.slice(0,16))}"></label><label><span>填报结束</span><input name="preferenceEnd" type="datetime-local" value="${h(selected?.payload?.preferenceEnd?.slice(0,16))}"></label></div><div class="field-row"><label><span>当前阶段</span><select name="status">${Object.entries(phaseLabels).map(([value,label]) => `<option value="${value}" ${value === (selected?.status || 'draft') ? 'selected' : ''}>${label}</option>`).join('')}</select></label><label><span>普通志愿数</span><input name="maxChoices" type="number" min="1" max="20" value="${h(selected?.payload?.maxChoices || 5)}"></label></div><div class="field-row"><label><span>最多提交次数</span><input name="maxSubmissions" type="number" min="1" max="50" value="${h(selected?.payload?.maxSubmissions || 3)}"><small>首次保存也计 1 次;达到上限自动锁定。</small></label><label><span>考生进度说明</span><input name="progress" value="${h(selected?.payload?.progress || '')}" placeholder="例如:招生学校正在审核投档名单"></label></div><label class="agreement"><input type="checkbox" name="enabled" ${selected?.payload?.enabled ? 'checked' : ''}><span>为本场考试启用志愿填报</span></label><label class="agreement"><input type="checkbox" name="autoPublish" ${selected?.payload?.autoPublish === false ? '' : 'checked'}><span>录取结束后自动脱敏公示录取结果与分数线</span></label><button class="solid-button" type="submit">保存志愿设置</button></form>${selected ? `<div class="admission-control-actions"><button class="solid-button" data-action="admission-match" data-exam-id="${h(selected.examId)}">按规则投档</button><button class="ghost-button" data-action="admission-finalize" data-exam-id="${h(selected.examId)}">结束录取并通知</button><button class="ghost-button" data-action="admission-supplementary" data-exam-id="${h(selected.examId)}">开启补录</button></div>` : ''}</section>`;
const accounts = `<section class="panel admission-account-panel"><div class="panel-title"><div><h2>招生学校账号</h2><p>只可绑定已在学校管理中标记为“招生校”的学校。</p></div><span>${data.schoolAccounts.length} 个</span></div><form data-form="admission-account"><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label><span>显示名称</span><input name="displayName" placeholder="学校招生办公室"></label><button class="solid-button" type="submit">创建招生学校账号</button></form></section>`;
const planForm = `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>代招生校上传计划</h2><p>每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。</p></div></div><form data-form="admission-plan"><div class="field-row"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label></div>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="可填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">代上传并审核通过</button></form></section>`;
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="table-scroll"><table><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${data.plans.map(plan => `<tr><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)}`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div></section>`;
@@ -174,6 +175,11 @@ export function createAdminViews(context) {
return `<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。</p></div><dl><div><dt>待审计划</dt><dd>${pendingPlans.length}</dd></div><div><dt>学校审核中</dt><dd>${data.placements.filter(item => item.status === 'school_review').length}</dd></div><div><dt>退档待审</dt><dd>${withdrawals.length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div></dl></section><div class="admission-admin-grid">${settings}${accounts}</div>${planForm}${plans}${placements}`;
}
function adminIndicatorQualifications(data) {
if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。');
return `<section class="qualification-ledger-intro"><div><span>SOURCE SCHOOL CERTIFICATION</span><h2>${h(data.school?.name)}资格确认簿</h2><p>确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。</p></div></section>${data.exams.map(item => { const status = item.qualificationStatus; return `<section class="panel qualification-ledger"><header><div><span>${h(item.exam.code)}</span><h2>${h(item.exam.name)}</h2></div><strong class="${status.complete ? 'complete' : ''}">${h(status.confirmed)} / ${h(status.total)} 已确认</strong></header><div class="qualification-progress"><i style="width:${status.total ? Math.round(status.confirmed / status.total * 100) : 0}%"></i></div>${status.complete ? '<div class="qualification-publication-state">✓ 本校资格已全部确认,公开公示已自动发布</div>' : '<div class="qualification-publication-state pending">未全部确认前不会公开,请逐项核对。</div>'}<div class="table-scroll"><table><thead><tr><th>报名号 / 姓名</th><th>特长类型</th><th>指标分配资格</th><th>确认时间</th><th>保存</th></tr></thead><tbody>${status.rows.map(row => `<tr data-exam-id="${h(item.examId)}" data-user-id="${h(row.userId)}"><td><strong>${h(row.name)}</strong><small class="mono">${h(row.registrationNumber)}</small></td><td>${h(row.specialtyLabel)}</td><td><select data-indicator-eligible><option value="" ${row.confirmed ? '' : 'selected'}>请选择</option><option value="true" ${row.confirmed && row.eligible ? 'selected' : ''}>有指标分配资格</option><option value="false" ${row.confirmed && !row.eligible ? 'selected' : ''}>无指标分配资格</option></select></td><td>${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}</td><td><button class="row-action primary" data-action="save-indicator-qualification">确认</button></td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">本校暂无资料已完善的在册考生</td></tr>'}</tbody></table></div></section>`; }).join('')}`;
}
function adminNotices(notices) {
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
}
+16 -10
View File
@@ -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 ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null;
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 `<div class="preference-choice-row"><b>${index + 1}</b><label><span>招生学校代码 / 学校</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}"><option value="">${index ? '' : ''}</option>${item.plans.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'}><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(category.remaining)}</option>`).join('')}</select></label></div>`;
}).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>${index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).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 `<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 qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
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><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></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>按学校代码填写志愿</strong><small>先匹配招生学校,再选择该校对本人开放的招生类别;仅你本人可保存和修改。</small></div><span>最多 ${h(item.payload.maxChoices)} 个</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿</button></form>` : choices.length ? `<div class="locked-preferences"><strong>已锁定志愿顺序</strong>${lockedRows}</div>` : '<div class="read-only-callout">当前不能填报:请等待成绩完整发布或志愿填报窗口开放。</div>'}</section>`;
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
}).join('')}</div>`;
}
+16 -4
View File
@@ -20,12 +20,12 @@ export function createPublicViews(context) {
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#home-admissions" data-action="scroll-to" data-target="home-admissions">录取公示</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#announcements" data-route="announcements">招生公示</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
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) {
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
${admissionAnnouncements.length ? `<section class="content-section public-admission-section" id="home-admissions"><div class="section-heading"><div><p class="overline">ADMISSION DISCLOSURE</p><h2>录取结果公示</h2></div><p>报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。</p></div>${admissionAnnouncements.map(announcement => `<article class="panel public-admission-board"><header><div><span>${formatDate(announcement.completedAt)}</span><h3>${h(announcement.examName)}</h3></div><strong>${announcement.rows.length} 人录取</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th><th>身份核验</th></tr></thead><tbody>${announcement.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td><td class="mono">${h(row.idNumberMasked)}</td></tr>`).join('')}</tbody></table></div></article>`).join('')}</section>` : ''}
<section class="content-section disclosure-entry"><div><p class="overline">ADMISSION DISCLOSURE</p><h2>招生录取公开卷宗</h2><p>指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。</p></div><button class="solid-button large" data-route="announcements">进入招生公示页 ${icons.arrow}</button></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
}
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()}<main class="public-main announcement-page"><section class="announcement-hero"><div><p class="overline">PUBLIC ADMISSION LEDGER</p><h1>招生录取<br><em>公开卷宗</em></h1><p>按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。</p></div><dl><div><dt>公开类别</dt><dd>${sections}</dd></div><div><dt>资格公示</dt><dd>${h(data.qualifications?.length || 0)}</dd></div><div><dt>录取公告</dt><dd>${h(data.admissions?.length || 0)}</dd></div></dl></section>
<nav class="announcement-index"><a href="#qualification-publications">01 指标资格</a><a href="#admission-publications">02 录取名单</a><a href="#cutoff-publications">03 录取分数线</a></nav>
<section class="content-section announcement-register" id="qualification-publications"><div class="section-heading"><div><p class="overline">QUALIFICATION REGISTER</p><h2>指标分配资格公示</h2></div><p>仅在生源校全部考生完成资格确认后自动发布。</p></div>${(data.qualifications || []).map(item => `<article class="panel announcement-sheet"><header><div><span>${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 人</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${item.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已完成全校确认的资格公示</div>'}</section>
<section class="content-section announcement-register" id="admission-publications"><div class="section-heading"><div><p class="overline">ADMISSION RESULTS</p><h2>最终录取名单</h2></div><p>录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。</p></div>${(data.admissions || []).map(item => `<article class="panel announcement-sheet"><header><div><span>${formatDate(item.publishedAt, true)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 人录取</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${item.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已完成的录取公告</div>'}</section>
<section class="content-section announcement-register" id="cutoff-publications"><div class="section-heading"><div><p class="overline">ADMISSION CUTOFFS</p><h2>录取分数线统计</h2></div><p>分数线为对应学校、招生类别最终录取考生的最低总成绩。</p></div>${(data.cutoffs || []).map(item => `<article class="panel announcement-sheet cutoff-sheet"><header><div><span>${formatDate(item.publishedAt, true)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 条分数线</strong></header><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${item.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已发布的录取分数线</div>'}</section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>招生公示数据由系统按业务状态自动生成</span></footer>`;
}
function renderHeroTicket(exam) {
const status = exam.registrationState;
@@ -72,5 +84,5 @@ export function createPublicViews(context) {
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
}
return { brand, renderHome, renderAuth };
return { brand, renderHome, renderAnnouncements, renderAuth };
}
+2 -1
View File
@@ -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,
+6 -1
View File
@@ -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();
+2 -2
View File
@@ -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,
+24
View File
@@ -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');
+48 -5
View File
@@ -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();
+44 -11
View File
@@ -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) {
+15 -3
View File
@@ -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);
}
+61 -5
View File
@@ -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';