准考证优化

This commit is contained in:
2026-07-20 14:18:37 +08:00 Unverified
parent 9566542414
commit c5b18d0b15
13 changed files with 447 additions and 57 deletions
+9 -7
View File
@@ -26,7 +26,7 @@ export function createAdminViews(context) {
const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证编排', '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
@@ -92,18 +92,20 @@ export function createAdminViews(context) {
const selectedExam = data.exams.find(exam => exam.approvedCount > 0) || data.exams[0];
const selectedPlan = selectedExam?.plan;
const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code;
const form = selectedExam ? `<section class="panel arrangement-config"><div class="panel-title"><div><h2>整场编排控制台</h2><p>先预检容量与档案,再以一个事务生成或替换整套编排。</p></div><span>${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位</span></div><form data-form="admission-arrangement"><div class="arrangement-fields"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selectedExam.id ? 'selected' : ''}>${h(exam.name)}(审核通过 ${exam.approvedCount} 人 / 已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label><label><span>混编范围 *</span><select name="mixingScope" required>${data.mixingScopes.map(item => `<option value="${h(item.code)}" ${item.code === (selectedPlan?.mixingScope || 'school') ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>准考证号规则 *</span><select name="numberRuleId" required>${data.rules.map(rule => `<option value="${h(rule.id)}" ${rule.id === selectedPlan?.numberRuleId ? 'selected' : ''}>${h(rule.name)}</option>`).join('')}</select></label><label><span>稳定编排种子</span><input name="seed" value="${h(selectedPlan?.randomSeed || selectedExam.code)}" maxlength="80"><small>相同数据与种子会得到相同顺序,便于复核。</small></label></div><div class="arrangement-actions"><button type="button" class="ghost-button" data-action="preview-arrangement">仅预检,不写入</button><button type="submit" class="solid-button">${selectedPlan ? '重新编排整场考试' : '生成整场编排'}</button></div></form><div data-arrangement-preview></div></section>` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。');
const ruleCards = `<section class="arrangement-rules">${data.rules.map(rule => `<article><span>号码规则</span><h3>${h(rule.name)}</h3><p>${h(rule.description)}</p><code>${h(rule.example)}</code></article>`).join('')}</section>`;
const planCards = data.plans.length ? `<section class="arrangement-plan-grid">${data.plans.map(plan => `<article class="panel"><header><div><span>${h(scopeName(plan.mixingScope))}</span><h3>${h(plan.examName)}</h3></div><time>${formatDate(plan.generatedAt, true)}</time></header><div><strong>${plan.candidateCount}</strong><small>名考生</small><strong>${plan.centerCount}</strong><small>个考点</small><strong>${plan.subjectCombinationCount}</strong><small>种科目组合</small></div><p>${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%</p>${plan.warnings.length ? `<ul>${plan.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<p class="arrangement-ok">容量、档案与时间冲突检查通过</p>'}</article>`).join('')}</section>` : '';
const form = data.canArrange ? (selectedExam ? `<section class="panel arrangement-config"><div class="panel-title"><div><h2>整场编排控制台</h2><p>先预检容量与档案,再以一个事务生成或替换整套编排。</p></div><span>${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位</span></div><form data-form="admission-arrangement"><div class="arrangement-fields"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selectedExam.id ? 'selected' : ''}>${h(exam.name)}(审核通过 ${exam.approvedCount} 人 / 已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label><label><span>混编范围 *</span><select name="mixingScope" required>${data.mixingScopes.map(item => `<option value="${h(item.code)}" ${item.code === (selectedPlan?.mixingScope || 'school') ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>准考证号规则 *</span><select name="numberRuleId" required>${data.rules.map(rule => `<option value="${h(rule.id)}" ${rule.id === selectedPlan?.numberRuleId ? 'selected' : ''}>${h(rule.name)}</option>`).join('')}</select></label><label><span>稳定编排种子</span><input name="seed" value="${h(selectedPlan?.randomSeed || selectedExam.code)}" maxlength="80"><small>相同数据与种子会得到相同顺序,便于复核。</small></label></div><div class="arrangement-actions"><button type="button" class="ghost-button" data-action="preview-arrangement">仅预检,不写入</button><button type="submit" class="solid-button">${selectedPlan ? '重新编排整场考试' : '生成整场编排'}</button></div></form><div data-arrangement-preview></div></section>` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。')) : '';
const ruleCards = data.canArrange ? `<section class="arrangement-rules">${data.rules.map(rule => `<article><span>号码规则</span><h3>${h(rule.name)}</h3><p>${h(rule.description)}</p><code>${h(rule.example)}</code></article>`).join('')}</section>` : '';
const planCards = data.canArrange && data.plans.length ? `<section class="arrangement-plan-grid">${data.plans.map(plan => `<article class="panel"><header><div><span>${h(scopeName(plan.mixingScope))}</span><h3>${h(plan.examName)}</h3></div><time>${formatDate(plan.generatedAt, true)}</time></header><div><strong>${plan.candidateCount}</strong><small>名考生</small><strong>${plan.centerCount}</strong><small>个考点</small><strong>${plan.subjectCombinationCount}</strong><small>种科目组合</small></div><p>${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%</p>${plan.warnings.length ? `<ul>${plan.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<p class="arrangement-ok">容量、档案与时间冲突检查通过</p>'}</article>`).join('')}</section>` : '';
const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0];
const exportPanel = exportExam ? `<section class="panel arrangement-config admission-export-panel"><div class="panel-title"><div><h2>${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}</h2><p>批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。</p></div><span>${approved.filter(item => item.admitCard).length} 份范围内准考证</span></div><div class="arrangement-fields"><label><span>选择考试</span><select data-admission-export-exam>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === exportExam.id ? 'selected' : ''}>${h(exam.name)}(已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label></div><div class="arrangement-actions"><button class="solid-button" data-action="batch-admit-download">批量下载准考证</button><button class="ghost-button" data-action="admit-info-export">导出准考证信息</button>${data.canExportCenterMaterials ? '<button class="ghost-button" data-action="center-materials-export">导出本校考点桌贴 / 门贴 / 签名单</button>' : ''}</div></section>` : emptyState('当前范围还没有考试报名', '待报名审核通过并完成整场编排后,可在这里批量下载。');
const rows = approved.map(reg => {
const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item]));
const subjects = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id);
return `<span><b>${h(subject.name)}</b><small>${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)}` : '待编排'}</small></span>`;
return `<span><b>${h(subject.name)}</b><small>${assignment ? `考试考场序号 ${h(assignment.examRoomCode)} · ${h(assignment.roomName || assignment.room)}(场地 ${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat)}` : '待编排'}</small></span>`;
}).join('');
return `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td class="mono">${h(reg.admitCard?.number || '待编排')}</td><td>${h(reg.admitCard?.testCenter || '—')}</td><td><div class="subject-room-list">${subjects}</div></td><td>${reg.admitCard ? `<button class="row-action" data-action="generate-admit" data-id="${h(reg.id)}">查看</button>` : '—'}</td></tr>`;
return `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td class="mono">${h(reg.admitCard?.number || '待编排')}</td><td><strong>${h(reg.admitCard?.testCenter || '—')}</strong><small>${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}</small></td><td><div class="subject-room-list">${subjects}</div></td><td>${reg.admitCard ? `<button class="row-action" data-action="generate-admit" data-id="${h(reg.id)}">查看</button>` : '—'}</td></tr>`;
}).join('');
return `${form}${ruleCards}${planCards}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号或考点"></label><p>同一考生所有科目固定在一个考点;考场和座位按科目分别展示。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点</th><th>分科考 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
return `${form}${ruleCards}${planCards}${exportPanel}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号或考点"></label><p>考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点 / 地址</th><th>分科考试考场 / 物理场地 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
}
function adminResults(data) {
+4 -4
View File
@@ -25,9 +25,9 @@ export function createCandidateViews(context) {
function adminNavForUser() {
const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]];
if (level === 'class') return [core[0], core[1], core[2], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[3]];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3]];
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3]];
}
@@ -137,9 +137,9 @@ export function createCandidateViews(context) {
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const subjectRows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '')} · ${h(assignment.seat || '')} </small></span>`;
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.examRoomCode || '')} · ${h(assignment.roomName || assignment.room || '')}${h(assignment.roomCode || '')}· ${h(assignment.building || '')} ${h(assignment.floor || '')} · ${h(assignment.seat || '')}</small></span>`;
}).join('');
return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>逐科安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`;
return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸横向打印</small></div></article>`;
}).join('')}</div>` : emptyState('', '', 'candidate/registrations', '');
}
+7 -7
View File
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026';
return {
meta: { version: 9, createdAt: nowIso() },
meta: { version: 10, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false },
organization: {
name: '海州市教育考试中心',
@@ -77,13 +77,13 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
admitCard: {
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点',
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', centerCode: 'HZ03-C01', centerAddress: '江苏省 连云港市 连云区 育才路 16 号',
room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [
{ subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '003', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '006', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '009', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '011', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }
{ subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
{ subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }
]
}
}
+19 -1
View File
@@ -88,6 +88,10 @@ export function createMysqlAdapter(context) {
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL',
'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
"ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_code VARCHAR(40) NOT NULL DEFAULT ''",
"ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_address VARCHAR(500) NOT NULL DEFAULT ''",
"ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS building VARCHAR(120) NOT NULL DEFAULT ''",
"ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS floor VARCHAR(80) NOT NULL DEFAULT ''",
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
];
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
@@ -119,6 +123,17 @@ export function createMysqlAdapter(context) {
metadataRows[0].schema_version = 9;
metadataRows[0].app_version = 9;
}
if (Number(metadataRows[0]?.schema_version || 1) < 10) {
await pool.execute(`UPDATE admit_cards card LEFT JOIN test_centers center ON center.id = card.center_id SET
card.center_code = COALESCE(center.code, card.center_code),
card.center_address = COALESCE(CONCAT_WS(' ', center.province_name, center.city_name, center.district_name, center.address), card.center_address)`);
await pool.execute(`UPDATE admit_card_subjects assignment LEFT JOIN test_rooms room ON room.id = assignment.room_id SET
assignment.building = COALESCE(room.building, assignment.building),
assignment.floor = COALESCE(room.floor, assignment.floor)`);
await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1');
metadataRows[0].schema_version = 10;
metadataRows[0].app_version = 10;
}
if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed();
const connection = await pool.getConnection();
@@ -290,6 +305,9 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.app_version || 1) < 7) {
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
}
if (Number(metadataRows[0]?.schema_version || 1) < 10) {
await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1');
}
}
if (!existing.length) {
const initialState = seed();
@@ -298,7 +316,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction();
const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 9, ?, ?, ?)
VALUES (1, 10, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
+8
View File
@@ -183,6 +183,8 @@ export const sqliteSchema = `
card_number TEXT NOT NULL UNIQUE,
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL,
center_code TEXT NOT NULL,
center_address TEXT NOT NULL,
generated_at TEXT NOT NULL
) STRICT;
@@ -193,6 +195,8 @@ export const sqliteSchema = `
room TEXT NOT NULL,
room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL,
building TEXT NOT NULL,
floor TEXT NOT NULL,
seat TEXT NOT NULL,
subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id),
@@ -687,6 +691,8 @@ export const mysqlSchema = [
card_number VARCHAR(100) NOT NULL,
center_id VARCHAR(64) NULL,
test_center VARCHAR(200) NOT NULL,
center_code VARCHAR(40) NOT NULL,
center_address VARCHAR(500) NOT NULL,
generated_at VARCHAR(35) NOT NULL,
PRIMARY KEY (registration_id),
UNIQUE KEY uq_admit_cards_number (card_number),
@@ -701,6 +707,8 @@ export const mysqlSchema = [
room VARCHAR(120) NOT NULL,
room_code VARCHAR(40) NOT NULL,
exam_room_code VARCHAR(20) NOT NULL,
building VARCHAR(120) NOT NULL,
floor VARCHAR(80) NOT NULL,
seat VARCHAR(30) NOT NULL,
subject_signature VARCHAR(1000) NOT NULL,
PRIMARY KEY (registration_id, subject_id),
+28 -3
View File
@@ -57,6 +57,12 @@ export function createSqliteAdapter(context) {
]);
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('admit_cards', [
['center_code', "TEXT NOT NULL DEFAULT ''"], ['center_address', "TEXT NOT NULL DEFAULT ''"]
]);
ensureColumns('admit_card_subjects', [
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
]);
if (tableExists('workflow_definitions')) {
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
if (!definitionSql.includes('candidate_account_batch')) {
@@ -144,13 +150,13 @@ export function createSqliteAdapter(context) {
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL, generated_at TEXT NOT NULL
test_center TEXT NOT NULL, center_code TEXT NOT NULL, center_address TEXT NOT NULL, generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE admit_card_subjects (
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
exam_room_code TEXT NOT NULL, building TEXT NOT NULL, floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat)
) STRICT;
CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
@@ -172,6 +178,22 @@ export function createSqliteAdapter(context) {
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 10) {
connection.prepare(`
UPDATE admit_cards SET
center_code = COALESCE((SELECT code FROM test_centers WHERE id = admit_cards.center_id), center_code),
center_address = COALESCE((SELECT trim(
COALESCE(province_name, '') || ' ' || COALESCE(city_name, '') || ' ' ||
COALESCE(district_name, '') || ' ' || COALESCE(address, '')
) FROM test_centers WHERE id = admit_cards.center_id), center_address)
`).run();
connection.prepare(`
UPDATE admit_card_subjects SET
building = COALESCE((SELECT building FROM test_rooms WHERE id = admit_card_subjects.room_id), building),
floor = COALESCE((SELECT floor FROM test_rooms WHERE id = admit_card_subjects.room_id), floor)
`).run();
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
@@ -322,6 +344,9 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 10) {
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run();
}
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
const initialState = seed();
@@ -329,7 +354,7 @@ export function createSqliteAdapter(context) {
try {
connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 9, ?, ?, ?)
VALUES (1, 10, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
+49 -9
View File
@@ -37,8 +37,11 @@ export function createAdminRoutes(context) {
logAction,
excelResourceNames,
excelRowsForResource,
admissionRowsForRegistrations,
centerMaterialRows,
importExcelResource,
admitCardHtml,
admitCardsHtml,
hashPassword,
verifyPassword,
randomBytes,
@@ -46,6 +49,7 @@ export function createAdminRoutes(context) {
nowIso,
sessions,
buildWorkbook,
buildCenterMaterialsWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
@@ -593,8 +597,9 @@ export function createAdminRoutes(context) {
return sendJson(response, 200, { ok: true, registrations });
}
if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') {
if (!requirePermission(user, response, '*')) return true;
const registrations = db.registrations.filter(item => item.status === 'approved').map(registration => {
if (!requirePermission(user, response, 'registrations.read')) return true;
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item));
const registrations = scopedRegistrations.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
});
@@ -604,26 +609,61 @@ export function createAdminRoutes(context) {
ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '',
mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope
}));
const exams = db.exams.map(exam => ({
const visibleExams = user.adminLevel === 'super' ? db.exams : db.exams.filter(exam => scopedRegistrations.some(item => item.examId === exam.id));
const exams = visibleExams.map(exam => ({
...publicExam(exam),
approvedCount: db.registrations.filter(item => item.examId === exam.id && item.status === 'approved').length,
arrangedCount: db.registrations.filter(item => item.examId === exam.id && item.admitCard).length,
approvedCount: scopedRegistrations.filter(item => item.examId === exam.id).length,
arrangedCount: scopedRegistrations.filter(item => item.examId === exam.id && item.admitCard).length,
plan: plans.find(item => item.examId === exam.id) || null
}));
const centerScope = user.adminLevel === 'super'
? db.testCenters
: user.adminLevel === 'school' ? db.testCenters.filter(item => item.schoolId === user.schoolId) : [];
return sendJson(response, 200, {
ok: true,
canArrange: user.adminLevel === 'super',
canExportCenterMaterials: user.adminLevel === 'school',
scopeLabel: adminScopeLabel(db, user),
exams,
registrations,
plans,
rules: db.admissionNumberRules.filter(item => item.active),
mixingScopes: admissionMixingScopes,
centers: db.testCenters.filter(item => item.status === 'active').map(center => ({
plans: user.adminLevel === 'super' ? plans : [],
rules: user.adminLevel === 'super' ? db.admissionNumberRules.filter(item => item.active) : [],
mixingScopes: user.adminLevel === 'super' ? admissionMixingScopes : [],
centers: centerScope.filter(item => item.status === 'active').map(center => ({
...center,
roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length,
capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0)
}))
});
}
const admissionExportMatch = pathname.match(/^\/api\/admin\/admission-exports\/(admit-cards|info|center-materials)$/);
if (request.method === 'GET' && admissionExportMatch) {
if (!requirePermission(user, response, 'registrations.read')) return true;
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const examId = cleanText(requestUrl.searchParams.get('examId'), 64);
const exam = db.exams.find(item => item.id === examId);
if (!exam) return sendError(response, 404, '请选择有效考试');
const type = admissionExportMatch[1];
if (type === 'center-materials') {
if (user.adminLevel !== 'school') return sendError(response, 403, '考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出');
const rows = centerMaterialRows(db, user.schoolId, exam.id);
if (!rows.length) return sendError(response, 404, '本校维护考点暂无该考试的已编排考生');
const buffer = Buffer.from(await buildCenterMaterialsWorkbook(rows, `${exam.name}${adminScopeLabel(db, user)}`));
return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-考点桌贴门贴签名单.xlsx`);
}
const scoped = db.registrations.filter(item => item.examId === exam.id && item.admitCard && registrationInScope(db, user, item));
if (!scoped.length) return sendError(response, 404, '当前范围暂无已生成的准考证');
if (type === 'admit-cards') {
const html = admitCardsHtml(db, scoped, `${exam.name}-${adminScopeLabel(db, user)}-准考证`);
const filename = encodeURIComponent(`${exam.name}-${adminScopeLabel(db, user)}-准考证批量打印.html`);
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' });
response.end(html);
return true;
}
const rows = admissionRowsForRegistrations(db, scoped);
const buffer = Buffer.from(await buildWorkbook('admit_cards', rows, { subtitle: `${exam.name}${adminScopeLabel(db, user)}` }));
return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-准考证信息.xlsx`);
}
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
if (request.method === 'PATCH' && registrationMatch) {
if (!requirePermission(user, response, 'registrations.review')) return true;
+9 -5
View File
@@ -208,23 +208,25 @@ export function buildAdmissionArrangement(db, options) {
roomId: room.id,
roomName: room.name,
roomCode: room.code,
building: room.building || '',
floor: room.floor || '',
examRoomCode: '',
seat: String(seatNumber).padStart(2, '0'),
subjectSignature: candidate.signature
};
assignments.get(candidate.id).push(assignment);
roomUses.push({ subjectId: subject.id, subjectOrder: subjectOrder.get(subject.id), centerCode: center.code, roomCode: room.code, roomId: room.id });
roomUses.push({ centerCode: center.code, roomCode: room.code, roomId: room.id });
seatIndex += 1;
}
}
}
const examRoomCodes = new Map([...new Map(roomUses.map(item => [`${item.subjectId}:${item.roomId}`, item])).values()]
.sort((left, right) => left.subjectOrder - right.subjectOrder || left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode))
.map((item, index) => [`${item.subjectId}:${item.roomId}`, String(index + 1).padStart(3, '0')]));
const examRoomCodes = new Map([...new Map(roomUses.map(item => [item.roomId, item])).values()]
.sort((left, right) => left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode) || left.roomId.localeCompare(right.roomId))
.map((item, index) => [item.roomId, String(index + 1).padStart(3, '0')]));
for (const subjectAssignments of assignments.values()) {
subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId));
subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(`${item.subjectId}:${item.roomId}`); });
subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(item.roomId); });
}
const sequenceCounters = new Map();
@@ -252,6 +254,8 @@ export function buildAdmissionArrangement(db, options) {
number,
centerId: center.id,
testCenter: center.name,
centerCode: center.code || '',
centerAddress: [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '),
generatedAt: options.generatedAt,
assignments: assignments.get(candidate.id)
};