From f26410341765e69f279c2fd4b3858c4e2133c717 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 12:18:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=95=E5=8F=96=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E2=80=9C=E5=BF=97=E6=84=BF=E5=A1=AB=E6=8A=A5?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E5=BF=AB=E7=85=A7=E2=80=9D=EF=BC=8C=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E9=98=B6=E6=AE=B5=E5=9D=87=E5=8F=AF=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E3=80=82=20=E5=BF=97=E6=84=BF=E5=8F=B0=E8=B4=A6=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=9C=AA=E5=A1=AB=E6=8A=A5=E3=80=81=E5=B7=B2=E5=A1=AB?= =?UTF-8?q?=E6=8A=A5=E3=80=81=E5=B7=B2=E9=94=81=E5=AE=9A=E3=80=81=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E6=9C=AA=E9=BD=90=E5=8F=8A=E4=B8=8D=E5=8F=AF=E8=A1=A5?= =?UTF-8?q?=E5=BD=95=E8=80=83=E7=94=9F=E3=80=82=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E8=80=83=E8=AF=95=E3=80=81=E7=94=9F=E6=BA=90=E5=AD=A6?= =?UTF-8?q?=E6=A0=A1=E3=80=81=E7=8A=B6=E6=80=81=E5=92=8C=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=AF=8D=E7=AD=9B=E9=80=89=E3=80=82=20=E5=BD=95=E5=8F=96?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E6=94=AF=E6=8C=81=E6=8C=89=E8=80=83=E8=AF=95?= =?UTF-8?q?=E3=80=81=E6=8B=9B=E7=94=9F=E5=AD=A6=E6=A0=A1=E3=80=81=E5=BD=95?= =?UTF-8?q?=E5=8F=96=E7=8A=B6=E6=80=81=E5=92=8C=E5=85=B3=E9=94=AE=E8=AF=8D?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=90=9C=E7=B4=A2=E3=80=82=20=E4=B8=A4?= =?UTF-8?q?=E7=B1=BB=20Excel=20=E5=9D=87=E6=8C=89=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=AD=9B=E9=80=89=E6=9D=A1=E4=BB=B6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E5=B9=B6=E5=8C=85=E5=90=AB=E5=AD=A6=E6=A0=A1?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E3=80=81=E6=8A=A5=E5=90=8D=E5=8F=B7=E3=80=81?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E3=80=81=E6=8A=A5=E5=88=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AD=89=E5=AE=8C=E6=95=B4=E4=BF=A1=E6=81=AF=E3=80=82=20?= =?UTF-8?q?=E5=85=A8=E5=B8=82=E5=8F=B0=E8=B4=A6=E4=BB=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AF=E5=AF=BC=E5=87=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 15 +++ excel.mjs | 31 ++++++ src/client/admin-views.mjs | 19 +++- src/routes/admin.routes.mjs | 185 ++++++++++++++++++++++++++++++++++-- styles.css | 27 ++++++ tests/system.test.mjs | 20 ++++ 6 files changed, 284 insertions(+), 13 deletions(-) diff --git a/app.js b/app.js index 9d2c8fc..986ddde 100644 --- a/app.js +++ b/app.js @@ -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: {} }; diff --git a/excel.mjs b/excel.mjs index 1d88396..ad9dad3 100644 --- a/excel.mjs +++ b/excel.mjs @@ -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: [ diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 497b5e6..736eaba 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -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', `

考生志愿只读台账

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

${data.preferences.length} 份
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮
${item.choices.map((choice, index) => `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(choice.schoolName || choice.schoolId)}${h([choice.schoolCode, choice.categoryName || choice.categoryCode].filter(Boolean).join(' · '))}`).join('')}
${formatDate(item.payload.submittedAt, true)}
没有符合条件的志愿记录
${pagination(preferencePage)}
`); + 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', `

考生志愿实时台账

同时显示未填报、已填报和已锁定考生;页面仅供监督,不提供代改入口。

当前筛选 ${preferencePage.total} / 共 ${rows.length} 人
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试 / 生源校轮次 / 状态志愿顺序提交与锁定
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.specialty)}${h(item.examName)}${h(item.sourceSchoolName)}${item.className ? ` · ${h(item.className)}` : ''}第 ${h(item.round)} 轮${h(item.fillStatus)}
${item.choices.map((choice, index) => `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(choice.schoolName || choice.schoolId)}${h([choice.schoolCode, choice.categoryName || choice.categoryCode].filter(Boolean).join(' · '))}`).join('') || '尚未填报志愿'}
${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次${item.submittedAt ? formatDate(item.submittedAt, true) : '暂无提交时间'} · ${h(item.lockStatus)}
没有符合条件的考生填报记录
${pagination(preferencePage)}
`); } 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 = `

志愿填报实时快照

不受填报阶段限制,可随时导出当前轮次全部考生的填报、未填报与锁定情况。

${preferenceSnapshots.length} 人
${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 `
${h(exam.code || 'EXAM')}

${h(exam.name || setting.examId)}

第 ${h(round)} 轮 · ${h(phaseLabels[setting.status] || setting.status)}

全部考生
${rows.length}
尚未填报
${rows.filter(item => item.status === 'unfilled').length}
已填报
${rows.filter(item => ['submitted', 'locked'].includes(item.status)).length}
已锁定
${rows.filter(item => item.status === 'locked').length}
`; + }).join('') || '

启用志愿填报后,可在这里随时导出实时台账。

'}
`; const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
补录不能手工开启,必须由招生学校提交报到情况和补录决定,并经超级管理员审批。` : ''}
`; const accountRows = accountPage.items.map(account => `
${h((account.displayName || '招').slice(0, 1))}
${h(account.displayName)}${h(account.id)}
${h(account.username)}${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')}${account.active ? '已启用' : '已停用'}${formatDate(account.createdAt, true)}
`).join(''); const accounts = `

招生学校账户管理

创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。

${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
${pagination(accountPage)}
`; @@ -241,10 +250,10 @@ export function createAdminViews(context) { const plans = `

招生计划审核与完成率

实时核对计划、正式录取和实际报到完成情况。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成实时完成率指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${h(plan.progress?.admissionRate || 0)}%正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}实际报到 ${h(plan.progress?.reportingRate || 0)}%${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; const reportingPage = paged(data.reportingRequests || [], 'adminReportingApprovalTable', 20); const reportingApprovals = `

报到情况与补录审批

学校决定审批通过后,统计数据和说明将自动进入公开通知。

${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length} 项待审批
${reportingPage.items.map(item => ``).join('') || ''}
考试 / 学校报到统计学校决定状态审批
${h(item.examName)}${h(item.schoolName)} · 第 ${h(item.payload?.round || 1)} 轮计划 ${h(item.progress.totalQuota)} 人 · 已报到 ${h(item.progress.reportedCount)} 人完成率 ${h(item.progress.reportingRate)}% · 缺额 ${h(item.progress.reportingGap)} 人${item.payload?.supplementDecision === 'supplement' ? '申请补录' : '不进行补录'}${h(item.payload?.decisionNote || '')}${badge(item.status)}${item.status === 'pending_approval' ? `` : h(item.payload?.approvalNote || '—')}
暂无报到与补录审批记录
${pagination(reportingPage)}
`; - const placements = `

投档与退档监督

支持跨页搜索考生、报名号、学校与类别;志愿保持只读。

共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${pagination(placementPage)}
`; + const placements = `

录取情况台账

支持跨页搜索、考试/学校/状态筛选,并按当前筛选结果导出 Excel。

当前筛选 ${placementPage.total} / 共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生 / 生源校考试 / 成绩录取学校类别录取 / 报到状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.sourceSchoolName || '生源校未登记')}${item.className ? ` · ${h(item.className)}` : ''}${h(item.examName || item.examId)}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.schoolCode || '')}${h(item.payload.categoryName)}${h(item.candidate.specialtyLabel || '普通生')}${badge(item.status)}报到:${h(item.reportingStatusLabel || '—')}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
没有符合条件的录取记录
${pagination(placementPage)}
`; const banner = `
ADMISSION COMMAND

中考招生录取控制台

计划完成率、考生报到与补录审批在同一条可审计链路中实时更新。

待审计划
${pendingPlans.length}
补录待审批
${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
已报到
${(data.reportingRequests || []).reduce((sum, item) => sum + Number(item.progress?.reportedCount || 0), 0)}
`; const sections = { - settings: `
${settings}
`, + settings: `
${settings}${preferenceSnapshotPanel}
`, accounts, plans: `${planForm}${plans}`, reporting: reportingApprovals, diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 1cd9b68..2e7f6a3 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,6 +1,6 @@ import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs'; import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs'; -import { 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, '只有超级管理员可以创建招生学校账号'); diff --git a/styles.css b/styles.css index c6374b4..8cc66bd 100644 --- a/styles.css +++ b/styles.css @@ -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; } +} diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 88c89a2..dd4742c 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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 === '测试考生新名'), '班级管理员应可查看本班成绩');