录取设置页新增“志愿填报实时快照”,任意阶段均可导出。
Build and publish Docker images / Test, build and publish (push) Successful in 14m13s
Build and publish Docker images / Test, build and publish (push) Successful in 14m13s
志愿台账覆盖未填报、已填报、已锁定、成绩未齐及不可补录考生。 支持按考试、生源学校、状态和关键词筛选。 录取台账支持按考试、招生学校、录取状态和关键词筛选搜索。 两类 Excel 均按页面当前筛选条件导出,并包含学校名称、报名号、轮次、报到状态等完整信息。 全市台账仅超级管理员可导出。
This commit is contained in:
@@ -515,6 +515,21 @@ document.addEventListener('click', async event => {
|
||||
toast(approved ? '审批完成并自动公开' : '已退回招生学校', approved ? (supplement ? '全部学校审批完成后将自动开启补录' : '报到统计已进入公开通知') : '招生学校可修改暂存数据后重新提交');
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'admission-ledger-export') {
|
||||
const ledger = target.dataset.ledger;
|
||||
if (!['preferences', 'placements'].includes(ledger)) return;
|
||||
const query = new URLSearchParams();
|
||||
if (target.dataset.examId) query.set('examId', target.dataset.examId);
|
||||
if (target.dataset.round) query.set('round', target.dataset.round);
|
||||
if (target.dataset.table) {
|
||||
const control = getTableControl(state, target.dataset.table);
|
||||
if (String(control.query || '').trim()) query.set('q', String(control.query).trim());
|
||||
if (control.status && control.status !== 'all') query.set('status', control.status);
|
||||
Object.entries(control.filters || {}).forEach(([key, value]) => { if (value) query.set(key, value); });
|
||||
}
|
||||
window.location.href = `/api/admin/admissions/${ledger}/export?${query}`;
|
||||
return;
|
||||
}
|
||||
if (action === 'clear-table-filters') {
|
||||
const tableId = target.dataset.target;
|
||||
state.tableFilters[tableId] = { query: '', status: 'all', filters: {} };
|
||||
|
||||
@@ -125,6 +125,37 @@ const resourceSpecs = {
|
||||
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
||||
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||
},
|
||||
admission_preferences: {
|
||||
title: '考生志愿填报实时台账', sheet: '志愿填报情况',
|
||||
columns: [
|
||||
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'],
|
||||
['round', '填报轮次', 12, 1], ['fillStatus', '填报状态', 16, '已填报'], ['lockStatus', '锁定状态', 16, '未锁定'],
|
||||
['submissionCount', '已提交次数', 14, 1], ['maxSubmissions', '提交次数上限', 16, 3],
|
||||
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||
['specialty', '特长生资格', 22, '普通生'], ['indicatorStatus', '指标分配资格', 18, '有资格'],
|
||||
['preferenceOrder', '志愿顺序', 14, 1], ['preferenceType', '志愿类型', 14, '普通志愿'],
|
||||
['targetSchoolCode', '志愿学校代码', 18, 'AD01'], ['targetSchoolName', '志愿学校', 28, '海州市高级中学'],
|
||||
['categoryName', '招生类别', 20, '普通生'], ['submittedAt', '最近提交时间', 24, '2026-07-22 10:18']
|
||||
],
|
||||
numberColumns: ['round', 'submissionCount', 'maxSubmissions', 'preferenceOrder'],
|
||||
numberFormats: { round: '0', submissionCount: '0', maxSubmissions: '0', preferenceOrder: '0' }
|
||||
},
|
||||
admission_placements: {
|
||||
title: '招生录取情况台账', sheet: '录取情况',
|
||||
columns: [
|
||||
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'], ['round', '录取轮次', 12, 1],
|
||||
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||
['specialty', '特长生资格', 22, '普通生'], ['culturalScore', '文化课总分', 14, 560], ['featureScore', '特征分', 12, 0], ['totalScore', '投档总分', 14, 560],
|
||||
['preferenceOrder', '命中志愿序号', 16, 1], ['admissionSchoolCode', '招生学校代码', 18, 'AD01'], ['admissionSchoolName', '招生学校', 28, '海州市高级中学'],
|
||||
['categoryName', '招生类别', 20, '普通生'], ['quotaBucket', '计划类型', 18, '普通计划'], ['admissionStatus', '录取状态', 18, '正式录取'],
|
||||
['reportingStatus', '报到状态', 16, '已报到'], ['noticeNumber', '录取通知书编号', 34, 'AD01-EX-2026-ZK-000001'],
|
||||
['withdrawalReason', '退档或放弃原因', 34, ''], ['updatedAt', '状态更新时间', 24, '2026-07-22 10:18']
|
||||
],
|
||||
numberColumns: ['round', 'culturalScore', 'featureScore', 'totalScore', 'preferenceOrder'],
|
||||
numberFormats: { round: '0', culturalScore: '0.00', featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||
},
|
||||
admission_reporting: {
|
||||
title: '录取考生报到状态维护表', sheet: '考生报到',
|
||||
columns: [
|
||||
|
||||
@@ -82,9 +82,11 @@ export function createAdminViews(context) {
|
||||
|
||||
function mountPreferenceLedger(data) {
|
||||
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><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>`);
|
||||
if (!container) return;
|
||||
const rows = data.preferenceRows || [];
|
||||
const preferencePage = paged(rows, 'adminPreferenceTable');
|
||||
const statusClass = { unfilled: 'pending', submitted: 'approved', locked: 'completed', unavailable: 'closed', ineligible: 'rejected' };
|
||||
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿实时台账</h2><p>同时显示未填报、已填报和已锁定考生;页面仅供监督,不提供代改入口。</p></div><span>当前筛选 ${preferencePage.total} / 共 ${rows.length} 人</span></div><div class="data-toolbar admission-ledger-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="examId" data-target="adminPreferenceTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><select data-table-filter="sourceSchoolId" data-target="adminPreferenceTable"><option value="">全部生源学校</option>${data.sourceSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminPreferenceTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminPreferenceTable" data-status="unfilled">尚未填报</button><button data-action="status-filter" data-target="adminPreferenceTable" data-status="submitted">已填报</button><button data-action="status-filter" data-target="adminPreferenceTable" data-status="locked">已锁定</button><button data-action="status-filter" data-target="adminPreferenceTable" data-status="unavailable">成绩未齐</button><button data-action="status-filter" data-target="adminPreferenceTable" data-status="ineligible">不可补录</button></div><div class="ledger-toolbar-actions"><button class="row-action" data-action="clear-table-filters" data-target="adminPreferenceTable">清除筛选</button><button class="row-action primary" data-action="admission-ledger-export" data-ledger="preferences" data-table="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-status="${h(item.status)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.specialty)}</small></td><td><strong>${h(item.examName)}</strong><small>${h(item.sourceSchoolName)}${item.className ? ` · ${h(item.className)}` : ''}</small></td><td>第 ${h(item.round)} 轮<small><span class="status status-${h(statusClass[item.status] || 'pending')}">${h(item.fillStatus)}</span></small></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('') || '<em class="preference-empty">尚未填报志愿</em>'}</div></td><td><strong>${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</strong><small>${item.submittedAt ? formatDate(item.submittedAt, true) : '暂无提交时间'} · ${h(item.lockStatus)}</small></td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的考生填报记录</td></tr>'}</tbody></table></div>${pagination(preferencePage)}</section>`);
|
||||
}
|
||||
|
||||
function adminDashboard(data) {
|
||||
@@ -234,6 +236,13 @@ export function createAdminViews(context) {
|
||||
const accountPage = paged(data.schoolAccounts, 'admissionAccountTable', 20);
|
||||
const planPage = paged(data.plans, 'adminAdmissionPlanTable', 20);
|
||||
const placementPage = paged(data.placements, 'adminPlacementTable');
|
||||
const preferenceSnapshots = data.preferenceRows || [];
|
||||
const preferenceSnapshotPanel = `<section class="panel admission-snapshot-console"><div class="panel-title"><div><h2>志愿填报实时快照</h2><p>不受填报阶段限制,可随时导出当前轮次全部考生的填报、未填报与锁定情况。</p></div><span>${preferenceSnapshots.length} 人</span></div><div class="snapshot-ledger-grid">${data.settings.filter(item => item.payload?.enabled).map(setting => {
|
||||
const exam = data.exams.find(item => item.id === setting.examId) || {};
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const rows = preferenceSnapshots.filter(item => item.examId === setting.examId && Number(item.round) === round);
|
||||
return `<article><div><span>${h(exam.code || 'EXAM')}</span><h3>${h(exam.name || setting.examId)}</h3><p>第 ${h(round)} 轮 · ${h(phaseLabels[setting.status] || setting.status)}</p></div><dl><div><dt>全部考生</dt><dd>${rows.length}</dd></div><div><dt>尚未填报</dt><dd>${rows.filter(item => item.status === 'unfilled').length}</dd></div><div><dt>已填报</dt><dd>${rows.filter(item => ['submitted', 'locked'].includes(item.status)).length}</dd></div><div><dt>已锁定</dt><dd>${rows.filter(item => item.status === 'locked').length}</dd></div></dl><button class="row-action primary" data-action="admission-ledger-export" data-ledger="preferences" data-exam-id="${h(setting.examId)}" data-round="${h(round)}">导出本轮填报情况</button></article>`;
|
||||
}).join('') || '<p class="empty-state">启用志愿填报后,可在这里随时导出实时台账。</p>'}</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></div><small class="workflow-hint">补录不能手工开启,必须由招生学校提交报到情况和补录决定,并经超级管理员审批。</small>` : ''}</section>`;
|
||||
const accountRows = accountPage.items.map(account => `<tr data-status="${account.active ? 'active' : 'disabled'}" data-school="${h(account.schoolId)}"><td><div class="person-cell"><span>${h((account.displayName || '招').slice(0, 1))}</span><div><strong>${h(account.displayName)}</strong><small>${h(account.id)}</small></div></div></td><td><strong class="mono">${h(account.username)}</strong></td><td><strong>${h(account.schoolName || '未绑定')}</strong><small class="mono">${h(account.schoolCode || '')}</small></td><td><span class="status status-${account.active ? 'approved' : 'closed'}">${account.active ? '已启用' : '已停用'}</span></td><td>${formatDate(account.createdAt, true)}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admission-account-password" data-id="${h(account.id)}">重置密码</button><button class="row-action ${account.active ? 'danger' : 'primary'}" data-action="toggle-admission-account" data-id="${h(account.id)}" data-active="${account.active ? 'false' : 'true'}">${account.active ? '停用账户' : '重新启用'}</button></div></td></tr>`).join('');
|
||||
const accounts = `<section class="panel admission-account-panel admission-account-management"><div class="panel-title"><div><h2>招生学校账户管理</h2><p>创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。</p></div><span>${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${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><div class="account-management-divider"><strong>已建账户</strong><span>保留历史记录,不提供物理删除</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admissionAccountTable" placeholder="跨页搜索学校、账号或显示名称"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="admissionAccountTable"><option value="">全部招生学校</option>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div></div><div class="filter-pills account-status-pills"><button type="button" class="active" data-action="status-filter" data-target="admissionAccountTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="active">已启用</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="disabled">已停用</button></div><div class="table-scroll"><table id="admissionAccountTable"><thead><tr><th>账户</th><th>登录账号</th><th>绑定招生学校</th><th>状态</th><th>创建时间</th><th>账户操作</th></tr></thead><tbody>${accountRows || '<tr><td colspan="6" class="empty-state">还没有招生学校账户</td></tr>'}</tbody></table></div>${pagination(accountPage)}</section>`;
|
||||
@@ -241,10 +250,10 @@ export function createAdminViews(context) {
|
||||
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核与完成率</h2><p>实时核对计划、正式录取和实际报到完成情况。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminAdmissionPlanTable" placeholder="搜索考试、学校、招生类别或指标学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr data-status="${h(plan.status)}"><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><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></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="6" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
const reportingPage = paged(data.reportingRequests || [], 'adminReportingApprovalTable', 20);
|
||||
const reportingApprovals = `<section class="panel data-panel"><div class="panel-title"><div><h2>报到情况与补录审批</h2><p>学校决定审批通过后,统计数据和说明将自动进入公开通知。</p></div><span>${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length} 项待审批</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminReportingApprovalTable" placeholder="搜索考试、学校、轮次、决定或说明"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminReportingApprovalTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminReportingApprovalTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="adminReportingApprovalTable" data-status="submitted">已提交</button><button data-action="status-filter" data-target="adminReportingApprovalTable" data-status="pending_approval">待审批</button><button data-action="status-filter" data-target="adminReportingApprovalTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="adminReportingApprovalTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="adminReportingApprovalTable"><thead><tr><th>考试 / 学校</th><th>报到统计</th><th>学校决定</th><th>状态</th><th>审批</th></tr></thead><tbody>${reportingPage.items.map(item => `<tr data-status="${h(item.status)}"><td><strong>${h(item.examName)}</strong><small>${h(item.schoolName)} · 第 ${h(item.payload?.round || 1)} 轮</small></td><td>计划 ${h(item.progress.totalQuota)} 人 · 已报到 ${h(item.progress.reportedCount)} 人<small>完成率 ${h(item.progress.reportingRate)}% · 缺额 ${h(item.progress.reportingGap)} 人</small></td><td><strong>${item.payload?.supplementDecision === 'supplement' ? '申请补录' : '不进行补录'}</strong><small>${h(item.payload?.decisionNote || '')}</small></td><td>${badge(item.status)}</td><td>${item.status === 'pending_approval' ? `<button class="row-action primary" data-action="review-admission-reporting" data-id="${h(item.id)}" data-approved="true" data-supplement="${item.payload?.supplementDecision === 'supplement'}">批准并公开</button><button class="row-action" data-action="review-admission-reporting" data-id="${h(item.id)}" data-approved="false">退回</button>` : h(item.payload?.approvalNote || '—')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无报到与补录审批记录</td></tr>'}</tbody></table></div>${pagination(reportingPage)}</section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><div><h2>投档与退档监督</h2><p>支持跨页搜索考生、报名号、学校与类别;志愿保持只读。</p></div><span>共 ${data.placements.length} 条</span></div><div class="data-toolbar placement-supervision-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPlacementTable" placeholder="搜索考生、报名号、投档学校、类别或退档理由"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminPlacementTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="school_review">学校审核中</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawal_pending">退档待审</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="final">正式录取</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawn">已退档</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="forfeited">未报到放弃</button></div><button class="row-action" data-action="clear-table-filters" data-target="adminPlacementTable">清除筛选</button></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr data-status="${h(item.status)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><div><h2>录取情况台账</h2><p>支持跨页搜索、考试/学校/状态筛选,并按当前筛选结果导出 Excel。</p></div><span>当前筛选 ${placementPage.total} / 共 ${data.placements.length} 条</span></div><div class="data-toolbar placement-supervision-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPlacementTable" placeholder="搜索考生、报名号、考试、学校、类别或退档理由"></label><div class="table-filter-selects"><select data-table-filter="examId" data-target="adminPlacementTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><select data-table-filter="schoolId" data-target="adminPlacementTable"><option value="">全部招生学校</option>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminPlacementTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="school_review">学校审核中</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawal_pending">退档待审</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="final">正式录取</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawn">已退档</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="forfeited">未报到放弃</button></div><div class="ledger-toolbar-actions"><button class="row-action" data-action="clear-table-filters" data-target="adminPlacementTable">清除筛选</button><button class="row-action primary" data-action="admission-ledger-export" data-ledger="placements" data-table="adminPlacementTable">导出当前筛选</button></div></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生 / 生源校</th><th>考试 / 成绩</th><th>录取学校</th><th>类别</th><th>录取 / 报到状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr data-status="${h(item.status)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.sourceSchoolName || '生源校未登记')}${item.className ? ` · ${h(item.className)}` : ''}</small></td><td><strong>${h(item.examName || item.examId)}</strong><small>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td><strong>${h(item.schoolName)}</strong><small class="mono">${h(item.schoolCode || '')}</small></td><td>${h(item.payload.categoryName)}<small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${badge(item.status)}<small>报到:${h(item.reportingStatusLabel || '—')}</small></td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
const banner = `<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.reportingRequests || []).filter(item => item.status === 'pending_approval').length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div><div><dt>已报到</dt><dd>${(data.reportingRequests || []).reduce((sum, item) => sum + Number(item.progress?.reportedCount || 0), 0)}</dd></div></dl></section>`;
|
||||
const sections = {
|
||||
settings: `<div class="admission-admin-grid single">${settings}</div>`,
|
||||
settings: `<div class="admission-admin-grid single">${settings}${preferenceSnapshotPanel}</div>`,
|
||||
accounts,
|
||||
plans: `${planForm}${plans}`,
|
||||
reporting: reportingApprovals,
|
||||
|
||||
+177
-8
@@ -1,6 +1,6 @@
|
||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||
import { admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { activePreference, admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
@@ -111,6 +111,132 @@ export function createAdminRoutes(context) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function admissionChoiceView(db, examId, choice) {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const categories = admissionRecords(db, 'plan', examId)
|
||||
.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 || ''
|
||||
};
|
||||
}
|
||||
|
||||
function admissionPreferenceSnapshotRows(db) {
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const statusLabel = { unfilled: '尚未填报', submitted: '已填报', locked: '已锁定', unavailable: '成绩未齐', ineligible: '不可补录' };
|
||||
const rows = [];
|
||||
for (const setting of admissionRecords(db, 'setting').filter(item => item.payload?.enabled)) {
|
||||
const exam = examById.get(setting.examId) || {};
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const maxSubmissions = Number(setting.payload?.maxSubmissions || 3);
|
||||
const registrations = db.registrations.filter(item => item.examId === setting.examId && item.status === 'approved');
|
||||
for (const registration of registrations) {
|
||||
const account = accountById.get(registration.userId);
|
||||
const profile = profileByUserId.get(registration.userId);
|
||||
if (!account?.active || account.role !== 'candidate' || !profile) continue;
|
||||
const preference = activePreference(db, setting.examId, registration.userId, round);
|
||||
const blockingPlacement = setting.status === 'supplementary'
|
||||
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === registration.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||
: null;
|
||||
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||
const status = blockingPlacement ? 'ineligible'
|
||||
: preference && submissionCount >= maxSubmissions ? 'locked'
|
||||
: preference ? 'submitted'
|
||||
: candidateTotalScore(db, setting.examId, registration.userId) == null ? 'unavailable' : 'unfilled';
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||
const schoolClass = classById.get(profile.classId) || {};
|
||||
const indicator = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === registration.userId && item.status === 'confirmed');
|
||||
rows.push({
|
||||
id: `${setting.examId}:${round}:${registration.userId}`,
|
||||
examId: setting.examId,
|
||||
examCode: exam.code || '',
|
||||
examName: exam.name || '',
|
||||
round,
|
||||
phase: setting.status,
|
||||
status,
|
||||
fillStatus: statusLabel[status],
|
||||
lockStatus: status === 'locked' ? '已锁定' : status === 'ineligible' ? '不可填报' : '未锁定',
|
||||
submissionCount,
|
||||
maxSubmissions,
|
||||
submittedAt: preference?.payload?.submittedAt || preference?.updatedAt || '',
|
||||
sourceSchoolId: sourceSchool.id || '',
|
||||
sourceSchoolCode: sourceSchool.code || '',
|
||||
sourceSchoolName: sourceSchool.name || '',
|
||||
className: schoolClass.name || profile.className || '',
|
||||
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生',
|
||||
indicatorStatus: indicator ? (indicator.payload?.eligible ? '有资格' : '无资格') : '未确认',
|
||||
candidate: { userId: registration.userId, registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '' },
|
||||
choices: (preference?.payload?.choices || []).map(choice => admissionChoiceView(db, setting.examId, choice))
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((left, right) => left.examName.localeCompare(right.examName, 'zh-CN') || left.candidate.registrationNumber.localeCompare(right.candidate.registrationNumber));
|
||||
}
|
||||
|
||||
function admissionPlacementLedgerRows(db) {
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const reportingByPlacement = new Map();
|
||||
const reportingRecords = admissionRecords(db, 'notification')
|
||||
.filter(item => item.payload?.type === 'admission_reporting')
|
||||
.sort((left, right) => new Date(left.updatedAt || left.createdAt) - new Date(right.updatedAt || right.createdAt));
|
||||
for (const record of reportingRecords) for (const row of record.payload?.rows || []) reportingByPlacement.set(row.placementId, row);
|
||||
const admissionStatusLabels = { school_review: '学校审核中', admitted: '拟录取', withdrawal_pending: '退档待审', final: '正式录取', withdrawn: '已退档', forfeited: '未报到放弃' };
|
||||
const reportingStatusLabels = { reported: '已报到', not_reported: '未报到', pending: '待确认' };
|
||||
return admissionRecords(db, 'placement').map(placement => {
|
||||
const exam = examById.get(placement.examId) || {};
|
||||
const school = schoolById.get(placement.schoolId) || {};
|
||||
const account = accountById.get(placement.userId) || {};
|
||||
const profile = profileByUserId.get(placement.userId) || {};
|
||||
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||
const schoolClass = classById.get(profile.classId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const reporting = reportingByPlacement.get(placement.id);
|
||||
return {
|
||||
...placement,
|
||||
examName: exam.name || '', examCode: exam.code || '',
|
||||
schoolName: school.name || '', schoolCode: school.code || '',
|
||||
sourceSchoolId: sourceSchool.id || '', sourceSchoolName: sourceSchool.name || '', sourceSchoolCode: sourceSchool.code || '',
|
||||
className: schoolClass.name || profile.className || '',
|
||||
candidate: { registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '', idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) || '普通生' },
|
||||
reportingStatus: reporting?.status || (placement.status === 'final' ? 'pending' : ''),
|
||||
reportingStatusLabel: reportingStatusLabels[reporting?.status] || (placement.status === 'final' ? '待确认' : '—'),
|
||||
admissionStatusLabel: admissionStatusLabels[placement.status] || placement.status
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function filterAdmissionLedgerRows(rows, searchParams) {
|
||||
const query = String(searchParams.get('q') || '').trim().toLowerCase();
|
||||
const filters = {
|
||||
examId: searchParams.get('examId') || '',
|
||||
schoolId: searchParams.get('schoolId') || '',
|
||||
sourceSchoolId: searchParams.get('sourceSchoolId') || '',
|
||||
status: searchParams.get('status') || '',
|
||||
round: searchParams.get('round') || ''
|
||||
};
|
||||
return rows.filter(item => {
|
||||
const haystack = JSON.stringify(item).toLowerCase();
|
||||
return Object.entries(filters).every(([key, value]) => !value || String(item[key] ?? item.payload?.[key] ?? '') === value)
|
||||
&& (!query || query.split(/\s+/).every(word => haystack.includes(word)));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAdmissionCategories(input) {
|
||||
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
||||
@@ -281,6 +407,53 @@ export function createAdminRoutes(context) {
|
||||
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
|
||||
}
|
||||
|
||||
const admissionLedgerExportMatch = pathname.match(/^\/api\/admin\/admissions\/(preferences|placements)\/export$/);
|
||||
if (admissionLedgerExportMatch && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以导出志愿与录取台账');
|
||||
const searchParams = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams;
|
||||
const kind = admissionLedgerExportMatch[1];
|
||||
const selectedExam = db.exams.find(item => item.id === searchParams.get('examId'));
|
||||
const subtitle = `${selectedExam?.name || '全部考试'}|按当前筛选条件导出|生成时间 ${new Date().toLocaleString('zh-CN', { hour12: false })}`;
|
||||
if (kind === 'preferences') {
|
||||
const snapshots = filterAdmissionLedgerRows(admissionPreferenceSnapshotRows(db), searchParams);
|
||||
const rows = snapshots.flatMap(item => {
|
||||
const choices = item.choices.length ? item.choices : [null];
|
||||
return choices.map((choice, index) => ({
|
||||
examCode: item.examCode, examName: item.examName, round: item.round,
|
||||
fillStatus: item.fillStatus, lockStatus: item.lockStatus,
|
||||
submissionCount: item.submissionCount, maxSubmissions: item.maxSubmissions,
|
||||
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||
specialty: item.specialty, indicatorStatus: item.indicatorStatus,
|
||||
preferenceOrder: choice ? Number(choice.order || index + 1) : '',
|
||||
preferenceType: choice ? (choice.preferenceType === 'indicator' ? '指标志愿' : '普通志愿') : '',
|
||||
targetSchoolCode: choice?.schoolCode || '', targetSchoolName: choice?.schoolName || '', categoryName: choice?.categoryName || choice?.categoryCode || '',
|
||||
submittedAt: item.submittedAt
|
||||
}));
|
||||
});
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_preferences', rows, { subtitle }));
|
||||
return sendWorkbook(response, buffer, `志愿填报实时台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
const placements = filterAdmissionLedgerRows(admissionPlacementLedgerRows(db), searchParams);
|
||||
const rows = placements.map(item => ({
|
||||
examCode: item.examCode, examName: item.examName, round: Number(item.payload?.round || 1),
|
||||
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||
specialty: item.candidate.specialtyLabel,
|
||||
culturalScore: Number(item.payload?.culturalScore ?? item.payload?.totalScore ?? 0),
|
||||
featureScore: Number(item.payload?.featureScore || 0), totalScore: Number(item.payload?.totalScore || 0),
|
||||
preferenceOrder: Number(item.payload?.preferenceOrder || 0),
|
||||
admissionSchoolCode: item.schoolCode, admissionSchoolName: item.schoolName,
|
||||
categoryName: item.payload?.categoryName || '',
|
||||
quotaBucket: String(item.payload?.quotaBucket || '').startsWith('indicator') ? '指标分配' : '普通计划',
|
||||
admissionStatus: item.admissionStatusLabel, reportingStatus: item.reportingStatusLabel,
|
||||
noticeNumber: item.payload?.noticeNumber || '', withdrawalReason: item.payload?.withdrawalReason || item.payload?.reportingNote || '',
|
||||
updatedAt: item.updatedAt || item.payload?.finalizedAt || ''
|
||||
}));
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_placements', rows, { subtitle }));
|
||||
return sendWorkbook(response, buffer, `招生录取情况台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
@@ -289,12 +462,7 @@ export function createAdminRoutes(context) {
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) }));
|
||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||
const placements = admissionRecords(db, 'placement').map(placement => {
|
||||
const account = userById.get(placement.userId) || {};
|
||||
const profile = profileByUserId.get(placement.userId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: schoolById.get(placement.schoolId)?.name || '' };
|
||||
});
|
||||
const placements = admissionPlacementLedgerRows(db);
|
||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||
const account = userById.get(preference.userId) || {};
|
||||
const profile = profileByUserId.get(preference.userId) || {};
|
||||
@@ -309,12 +477,13 @@ export function createAdminRoutes(context) {
|
||||
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||
}) };
|
||||
});
|
||||
const preferenceRows = admissionPreferenceSnapshotRows(db);
|
||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
|
||||
const school = schoolById.get(item.schoolId);
|
||||
return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' };
|
||||
});
|
||||
const reportingRequests = admissionRecords(db, 'notification').filter(item => item.userId == null && item.payload?.type === 'admission_reporting').map(item => ({ ...item, schoolName: schoolById.get(item.schoolId)?.name || '', examName: examById.get(item.examId)?.name || '', progress: admissionPlanProgress(db, admissionRecords(db, 'plan', item.examId).find(plan => plan.schoolId === item.schoolId) || { examId: item.examId, schoolId: item.schoolId, payload: { categories: [] } }) }));
|
||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, reportingRequests, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, preferenceRows, placements, reportingRequests, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
||||
|
||||
+27
@@ -1081,3 +1081,30 @@ body.review-subpage-open { overflow:hidden; }
|
||||
.candidate-stamp { position:static; display:inline-block; margin-bottom:16px; }
|
||||
.reporting-actions { align-items:stretch; flex-direction:column; }
|
||||
}
|
||||
|
||||
.admission-snapshot-console { overflow:hidden; padding:0; }
|
||||
.admission-snapshot-console > .panel-title { padding:22px 24px 18px; border-bottom:1px solid #dfe7eb; background:linear-gradient(110deg,#f4f8fa,#eef6f3); }
|
||||
.snapshot-ledger-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px; padding:18px 24px 24px; }
|
||||
.snapshot-ledger-grid article { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:16px 22px; align-items:center; padding:18px; border:1px solid #dce5e8; border-radius:11px; background:#fff; box-shadow:0 6px 18px rgba(28,55,73,.04); }
|
||||
.snapshot-ledger-grid article > div > span { color:#2a7280; font:700 11px Consolas,monospace; letter-spacing:.08em; }
|
||||
.snapshot-ledger-grid h3 { margin:5px 0 4px; color:#18334d; font-size:17px; }
|
||||
.snapshot-ledger-grid p { margin:0; color:#758192; font-size:12px; }
|
||||
.snapshot-ledger-grid dl { display:grid; grid-template-columns:repeat(4,minmax(58px,1fr)); grid-column:1/-1; gap:8px; margin:0; }
|
||||
.snapshot-ledger-grid dl div { padding:10px 11px; border-radius:8px; background:#f4f7f8; }
|
||||
.snapshot-ledger-grid dt { color:#788492; font-size:11px; }
|
||||
.snapshot-ledger-grid dd { margin:4px 0 0; color:#173e4a; font-size:20px; font-weight:800; }
|
||||
.snapshot-ledger-grid article > button { grid-column:2; grid-row:1; }
|
||||
.admission-ledger-toolbar { align-items:center; flex-wrap:wrap; }
|
||||
.admission-ledger-toolbar .search-box,.placement-supervision-toolbar .search-box { flex:1 1 320px; }
|
||||
.admission-ledger-toolbar .filter-pills,.placement-supervision-toolbar .filter-pills { flex:1 1 100%; }
|
||||
.ledger-toolbar-actions { display:flex; gap:8px; margin-left:auto; }
|
||||
.preference-empty { display:block; padding:13px; border:1px dashed #cdd8de; border-radius:8px; color:#7b8794; font-style:normal; text-align:center; background:#fafcfc; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.snapshot-ledger-grid { grid-template-columns:1fr; padding:14px; }
|
||||
.snapshot-ledger-grid article { grid-template-columns:1fr; }
|
||||
.snapshot-ledger-grid article > button { grid-column:auto; grid-row:auto; }
|
||||
.snapshot-ledger-grid dl { grid-template-columns:repeat(2,1fr); }
|
||||
.ledger-toolbar-actions { width:100%; margin-left:0; }
|
||||
.ledger-toolbar-actions button { flex:1; }
|
||||
}
|
||||
|
||||
@@ -702,6 +702,14 @@ try {
|
||||
const reportingSheet = reportingWorkbook.getWorksheet('考生报到');
|
||||
assert.ok(reportingSheet.getRow(2).values.includes('报到状态码*(Y/N/P)'), '报到 Excel 应明确提供 Y/N/P 状态码列');
|
||||
assert.equal(reportingSheet.getCell('G3').value, 'P', '新报到批次导出时应默认为待确认状态码 P');
|
||||
const placementLedgerExport = await admin.request(`/api/admin/admissions/placements/export?examId=${exam.id}&schoolId=${admissionOnlySchool.data.school.id}&status=final&q=${encodeURIComponent(placementCandidates[0].registrationNumber)}`);
|
||||
assert.equal(placementLedgerExport.response.status, 200, '超级管理员应能按考试、招生学校、状态和搜索词导出录取情况台账');
|
||||
const placementLedgerWorkbook = new ExcelJS.Workbook(); await placementLedgerWorkbook.xlsx.load(placementLedgerExport.data);
|
||||
const placementLedgerSheet = placementLedgerWorkbook.getWorksheet('录取情况');
|
||||
assert.ok(placementLedgerSheet.getRow(2).values.includes('录取状态'), '录取情况台账应包含录取状态列');
|
||||
assert.equal(placementLedgerSheet.rowCount, 3, '录取台账导出应严格应用当前筛选条件,只保留命中的一名考生');
|
||||
assert.equal(placementLedgerSheet.getRow(3).values.includes(placementCandidates[0].registrationNumber), true, '筛选后的录取台账应包含搜索命中的报名号');
|
||||
assert.equal((await classAdmin.request(`/api/admin/admissions/placements/export?examId=${exam.id}`)).response.status, 403, '非超级管理员不得导出全市录取台账');
|
||||
|
||||
const finalizedInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||
const finalizedPlacementRow = finalizedInspector.prepare('SELECT * FROM admission_records WHERE id = ?').get(placementIds[0]);
|
||||
@@ -1106,6 +1114,9 @@ try {
|
||||
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
||||
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
|
||||
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
|
||||
const unfilledPreferenceLedger = await admin.request('/api/admin/admissions');
|
||||
const unfilledPreferenceRow = unfilledPreferenceLedger.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||
assert.equal(unfilledPreferenceRow.status, 'unfilled', '实时志愿台账应包含尚未填报的合格考生,而不只是已提交志愿');
|
||||
const candidateAdmissions = await candidate.request('/api/candidate/admissions');
|
||||
const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
|
||||
assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
|
||||
@@ -1124,6 +1135,15 @@ try {
|
||||
const adminLockedChoice = adminPreferenceView.data.preferences.find(item => item.id === firstPreference.data.preference.id).choices[0];
|
||||
assert.equal(adminLockedChoice.schoolName, '海州市招生实验学校', '管理员志愿台账应显示完整学校名称');
|
||||
assert.equal(adminLockedChoice.categoryName, '普通生', '管理员志愿台账应显示招生类别名称而不是类别代码');
|
||||
const lockedSnapshot = adminPreferenceView.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||
assert.equal(lockedSnapshot.status, 'locked', '考生提交达到次数上限后,管理员实时台账应同步显示已锁定');
|
||||
const preferenceLedgerExport = await admin.request(`/api/admin/admissions/preferences/export?examId=${exam.id}&round=${lockedSnapshot.round}&status=locked&q=${encodeURIComponent(candidateNumber)}`);
|
||||
assert.equal(preferenceLedgerExport.response.status, 200, '超级管理员应能在填报期间按当前搜索和筛选条件导出志愿台账');
|
||||
const preferenceLedgerWorkbook = new ExcelJS.Workbook(); await preferenceLedgerWorkbook.xlsx.load(preferenceLedgerExport.data);
|
||||
const preferenceLedgerSheet = preferenceLedgerWorkbook.getWorksheet('志愿填报情况');
|
||||
assert.ok(preferenceLedgerSheet.getRow(2).values.includes('填报状态'), '志愿台账应包含填报状态列');
|
||||
assert.equal(preferenceLedgerSheet.rowCount, 4, '一名考生的两个志愿应导出为两条明细,并保留两行表头');
|
||||
assert.equal(preferenceLedgerSheet.getRow(3).values.includes('海州市招生实验学校'), true, '志愿台账应导出学校名称而不是只导出代码');
|
||||
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
|
||||
const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||
|
||||
Reference in New Issue
Block a user