diff --git a/app.js b/app.js
index 77435ef..eee777f 100644
--- a/app.js
+++ b/app.js
@@ -111,6 +111,14 @@ document.addEventListener('click', async event => {
setModal(`
${h(notice.category)}${h(notice.title)}
${formatDate(notice.publishAt,true)} · ${h(notice.author)}
${h(notice.content).replace(/\n/g, '
')}
`); return;
}
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
+ if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
+ event.preventDefault();
+ const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
+ if (!examId) return toast('请选择考试', '没有可导出的考试范围');
+ const type = { 'batch-admit-download': 'admit-cards', 'admit-info-export': 'info', 'center-materials-export': 'center-materials' }[action];
+ window.location.href = `/api/admin/admission-exports/${type}?examId=${encodeURIComponent(examId)}`;
+ return;
+ }
if (action === 'new-notice') return openNoticeForm();
if (action === 'new-exam') return openExamForm();
if (action === 'new-admin') return openAdminForm();
@@ -562,9 +570,9 @@ function openAdmitPreview(reg) {
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const rows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
- return `| ${h(subject.name)} | ${h(subject.date)} ${h(subject.start)} | ${h(assignment.roomName || assignment.room || '待定')} | ${h(assignment.seat || '—')} |
`;
+ return `| ${h(subject.name)} | ${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 || '—')} |
`;
}).join('');
- setModal(`ADMISSION CARD准考证编排明细
${h(reg.candidate?.name)} · ${h(reg.exam.name)}
${h(reg.admitCard.number)}- 固定考点
- ${h(reg.admitCard.testCenter)}
- 生成时间
- ${formatDate(reg.admitCard.generatedAt,true)}
考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。
`);
+ setModal(`ADMISSION CARD准考证编排明细
${h(reg.candidate?.name)} · ${h(reg.exam.name)}
${h(reg.admitCard.number)}- 固定考点
- ${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
- 生成时间
- ${formatDate(reg.admitCard.generatedAt,true)}
| 科目 | 时间 | 考试考场序号 | 考场通用名称 / 场地代码 | 楼栋 / 楼层 | 座位 |
${rows}
“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。
`);
}
window.addEventListener('hashchange', renderRoute);
diff --git a/database.mjs b/database.mjs
index db59193..8787f56 100644
--- a/database.mjs
+++ b/database.mjs
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
add(
- 'UPDATE schema_metadata SET schema_version = 9, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
+ 'UPDATE schema_metadata SET schema_version = 10, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
@@ -219,16 +219,17 @@ function buildSeedOperations(state) {
const card = registration.admitCard;
add(
`INSERT INTO admit_cards (
- registration_id, plan_id, card_number, center_id, test_center, generated_at
- ) VALUES (?, ?, ?, ?, ?, ?)`,
- registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, card.generatedAt
+ registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter,
+ card.centerCode || '', card.centerAddress || '', card.generatedAt
);
for (const assignment of card.assignments || []) add(
`INSERT INTO admit_card_subjects (
- registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room,
- assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature || ''
+ assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature || ''
);
}
@@ -374,6 +375,8 @@ function stateFromRows(rows) {
room: row.room,
roomCode: row.room_code,
examRoomCode: row.exam_room_code,
+ building: row.building || '',
+ floor: row.floor || '',
seat: row.seat,
subjectSignature: row.subject_signature || ''
});
@@ -390,6 +393,8 @@ function stateFromRows(rows) {
number: row.card_number,
centerId: row.center_id,
testCenter: row.test_center,
+ centerCode: row.center_code || '',
+ centerAddress: row.center_address || '',
room: primary.roomName || '',
seat: primary.seat || '',
assignments,
@@ -1265,16 +1270,17 @@ function createRepository({ client, location, read, transaction, close }) {
for (const card of cards) {
operations.push(operation(
`INSERT INTO admit_cards (
- registration_id, plan_id, card_number, center_id, test_center, generated_at
- ) VALUES (?, ?, ?, ?, ?, ?)`,
- card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, card.generatedAt
+ registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter,
+ card.centerCode || '', card.centerAddress || '', card.generatedAt
));
for (const assignment of card.assignments) operations.push(operation(
`INSERT INTO admit_card_subjects (
- registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName,
- assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature
+ assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature
));
}
operations.push(auditOperation(log));
diff --git a/excel.mjs b/excel.mjs
index ccfbb95..fad2da3 100644
--- a/excel.mjs
+++ b/excel.mjs
@@ -78,6 +78,18 @@ const resourceSpecs = {
],
validations: { published: ['发布', '不发布'] },
numberColumns: ['score']
+ },
+ admit_cards: {
+ title: '准考证信息台账', sheet: '准考证信息',
+ columns: [
+ ['schoolName', '考生学校', 24, ''], ['className', '班级', 20, ''],
+ ['candidateNumber', '报名号', 24, ''], ['candidateName', '姓名', 14, ''], ['idNumber', '证件号码', 22, ''],
+ ['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''], ['cardNumber', '准考证号', 22, ''],
+ ['centerCode', '考点代码', 16, ''], ['centerName', '考点名称', 26, ''], ['centerAddress', '考点详细地址', 38, ''],
+ ['subjectName', '科目', 14, ''], ['subjectDate', '日期', 14, ''], ['subjectTime', '时间', 16, ''],
+ ['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''],
+ ['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, '']
+ ]
}
};
@@ -173,3 +185,133 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } };
return workbook.xlsx.writeBuffer();
}
+
+function addOperationalSheet(workbook, name, title, columns, rows, { landscape = true } = {}) {
+ const sheet = workbook.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] });
+ sheet.columns = columns.map(([key, , width]) => ({ key, width }));
+ sheet.mergeCells(1, 1, 1, columns.length);
+ const titleCell = sheet.getCell(1, 1);
+ titleCell.value = title;
+ titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
+ titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } };
+ titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
+ sheet.getRow(1).height = 34;
+ const header = sheet.getRow(2);
+ header.values = columns.map(([, label]) => label);
+ header.height = 28;
+ header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } };
+ header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } };
+ header.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
+ rows.forEach((item, index) => {
+ const row = sheet.addRow(Object.fromEntries(columns.map(([key]) => [key, item[key] ?? ''])));
+ row.height = 28;
+ row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
+ row.alignment = { vertical: 'middle', wrapText: true };
+ if (index % 2 === 1) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F7F8' } };
+ row.eachCell(cell => { cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; });
+ });
+ sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: columns.length } };
+ sheet.pageSetup = { orientation: landscape ? 'landscape' : 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: .25, right: .25, top: .4, bottom: .4, header: .2, footer: .2 } };
+ sheet.printTitlesRow = '1:2';
+ return sheet;
+}
+
+function addDeskStickerSheet(workbook, rows) {
+ const sheet = workbook.addWorksheet('桌贴', { views: [{ showGridLines: false, zoomScale: 85 }] });
+ const labelColumns = [14.5, 14.5, 14.5, 3, 14.5, 14.5, 14.5];
+ labelColumns.forEach((width, index) => { sheet.getColumn(index + 1).width = width; });
+ sheet.pageSetup = {
+ orientation: 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0,
+ margins: { left: .25, right: .25, top: .25, bottom: .25, header: 0, footer: 0 },
+ horizontalCentered: true, verticalCentered: false
+ };
+ sheet.headerFooter = { oddFooter: '&C第 &P / &N 页' };
+ const rowsPerLabel = 8;
+ const labelsPerPage = 6;
+ const rowsPerPage = rowsPerLabel * 3;
+ const borderColor = { argb: 'FF173F60' };
+ const paleBlue = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF1F5' } };
+ const paleGold = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF1C7' } };
+ const mergeLine = (row, startColumn) => {
+ sheet.mergeCells(row, startColumn, row, startColumn + 2);
+ return sheet.getCell(row, startColumn);
+ };
+ const styleLine = (cell, { size = 10, bold = false, color = 'FF243B4A', fill = null } = {}) => {
+ cell.font = { name: '微软雅黑', size, bold, color: { argb: color } };
+ cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true, shrinkToFit: true };
+ if (fill) cell.fill = fill;
+ };
+
+ rows.forEach((item, index) => {
+ const page = Math.floor(index / labelsPerPage);
+ const slot = index % labelsPerPage;
+ const startRow = page * rowsPerPage + Math.floor(slot / 2) * rowsPerLabel + 1;
+ const startColumn = slot % 2 === 0 ? 1 : 5;
+ for (let row = startRow; row < startRow + rowsPerLabel; row += 1) sheet.getRow(row).height = 32.5;
+ const cells = Array.from({ length: rowsPerLabel }, (_, offset) => mergeLine(startRow + offset, startColumn));
+ cells[0].value = item.examName;
+ styleLine(cells[0], { size: 11, bold: true, color: 'FFFFFFFF', fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } } });
+ cells[1].value = `${item.subjectName}|${item.subjectDate} ${item.subjectTime}`;
+ styleLine(cells[1], { size: 9, bold: true, fill: paleBlue });
+ cells[2].value = '考试考场序号';
+ styleLine(cells[2], { size: 9, bold: true, color: 'FF2C7080' });
+ cells[3].value = { richText: [{ text: String(item.examRoomCode || '') }] };
+ cells[3].numFmt = '@';
+ styleLine(cells[3], { size: 25, bold: true, color: 'FF173F60' });
+ cells[4].value = `座位号 ${item.seat}`;
+ styleLine(cells[4], { size: 20, bold: true, color: 'FF8A4B08', fill: paleGold });
+ cells[5].value = `${item.roomName}(场地 ${item.roomCode})|${item.building} ${item.floor}`;
+ styleLine(cells[5], { size: 9, bold: true });
+ cells[6].value = `${item.candidateName}|${item.schoolName} ${item.className}`;
+ styleLine(cells[6], { size: 10, bold: true, fill: paleBlue });
+ cells[7].value = `${item.centerName}|准考证号 ${item.cardNumber}`;
+ styleLine(cells[7], { size: 8, color: 'FF526576' });
+
+ for (let row = startRow; row < startRow + rowsPerLabel; row += 1) {
+ for (let column = startColumn; column < startColumn + 3; column += 1) {
+ const cell = sheet.getCell(row, column);
+ cell.border = {
+ top: { style: row === startRow ? 'medium' : 'hair', color: borderColor },
+ bottom: { style: row === startRow + rowsPerLabel - 1 ? 'medium' : 'hair', color: borderColor },
+ left: { style: column === startColumn ? 'medium' : 'hair', color: borderColor },
+ right: { style: column === startColumn + 2 ? 'medium' : 'hair', color: borderColor }
+ };
+ }
+ }
+ if ((index + 1) % labelsPerPage === 0 && index + 1 < rows.length) sheet.getRow(startRow + rowsPerLabel - 1).addPageBreak();
+ });
+ if (rows.length) sheet.pageSetup.printArea = `A1:G${Math.ceil(rows.length / labelsPerPage) * rowsPerPage}`;
+ return sheet;
+}
+
+export async function buildCenterMaterialsWorkbook(rows, subtitle = '') {
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = '衡准考试信息管理系统';
+ workbook.created = new Date();
+ const sorted = [...rows].sort((a, b) => String(a.centerCode).localeCompare(String(b.centerCode))
+ || String(a.subjectDate).localeCompare(String(b.subjectDate)) || String(a.subjectTime).localeCompare(String(b.subjectTime))
+ || String(a.examRoomCode).localeCompare(String(b.examRoomCode)) || String(a.seat).localeCompare(String(b.seat)));
+ const baseTitle = subtitle ? `|${subtitle}` : '';
+ addDeskStickerSheet(workbook, sorted);
+
+ const doorMap = new Map();
+ for (const item of sorted) {
+ const key = [item.examCode, item.subjectName, item.centerCode, item.examRoomCode, item.roomCode].join('|');
+ const current = doorMap.get(key) || { ...item, candidateCount: 0 };
+ current.candidateCount += 1;
+ doorMap.set(key, current);
+ }
+ addOperationalSheet(workbook, '门贴', `考场门贴${baseTitle}`, [
+ ['examName', '考试', 28], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16],
+ ['centerName', '考点', 24], ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20],
+ ['roomCode', '场地代码', 14], ['building', '楼栋', 16], ['floor', '楼层', 10], ['candidateCount', '人数', 10]
+ ], [...doorMap.values()]);
+
+ addOperationalSheet(workbook, '考场签名单', `考场考生签名单${baseTitle}`, [
+ ['examName', '考试', 26], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16],
+ ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], ['building', '楼栋', 16], ['floor', '楼层', 10],
+ ['seat', '座位号', 10], ['cardNumber', '准考证号', 22], ['candidateName', '姓名', 14],
+ ['schoolName', '考生学校', 24], ['className', '班级', 18], ['signature', '考生签名', 18]
+ ], sorted.map(item => ({ ...item, signature: '' })));
+ return workbook.xlsx.writeBuffer();
+}
diff --git a/server.mjs b/server.mjs
index af92bbc..98946eb 100644
--- a/server.mjs
+++ b/server.mjs
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
import { createDatabase } from './database.mjs';
-import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
+import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
import { createAdminRoutes } from './src/routes/admin.routes.mjs';
import { createCandidateRoutes } from './src/routes/candidate.routes.mjs';
import { createAuthRoutes } from './src/routes/auth.routes.mjs';
@@ -352,9 +352,44 @@ function logAction(db, user, action, detail) {
const excelResourceNames = {
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
- account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账'
+ account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账',
+ admit_cards: '准考证信息台账'
};
+function admissionRowsForRegistrations(db, registrations) {
+ return registrations.flatMap(registration => {
+ const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
+ const account = db.users.find(item => item.id === registration.userId) || {};
+ const exam = db.exams.find(item => item.id === registration.examId) || { subjects: [] };
+ const school = db.schools.find(item => item.id === profile.schoolId);
+ const schoolClass = db.classes.find(item => item.id === profile.classId);
+ const card = registration.admitCard;
+ if (!card) return [];
+ const center = db.testCenters.find(item => item.id === card.centerId) || {};
+ return (card.assignments || []).map(assignment => {
+ const subject = exam.subjects.find(item => item.id === assignment.subjectId) || {};
+ const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
+ return {
+ schoolName: school?.name || profile.school || '', className: schoolClass?.name || profile.grade || '',
+ candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', idNumber: profile.idNumber || '',
+ examCode: exam.code || '', examName: exam.name || '', cardNumber: card.number || '',
+ centerCode: card.centerCode || center.code || '', centerName: card.testCenter || center.name || '',
+ centerAddress: card.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '),
+ subjectName: subject.name || '', subjectDate: subject.date || '', subjectTime: [subject.start, subject.end].filter(Boolean).join('—'),
+ examRoomCode: assignment.examRoomCode || '', roomName: assignment.roomName || assignment.room || room.name || '',
+ roomCode: assignment.roomCode || room.code || '', building: assignment.building || room.building || '',
+ floor: assignment.floor || room.floor || '', seat: assignment.seat || ''
+ };
+ });
+ });
+}
+
+function centerMaterialRows(db, schoolId, examId) {
+ const centerIds = new Set(db.testCenters.filter(item => item.schoolId === schoolId).map(item => item.id));
+ const registrations = db.registrations.filter(item => item.admitCard && item.examId === examId && centerIds.has(item.admitCard.centerId));
+ return admissionRowsForRegistrations(db, registrations);
+}
+
function excelRowsForResource(db, user, resource, searchParams) {
const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId);
if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({
@@ -405,6 +440,11 @@ function excelRowsForResource(db, user, resource, searchParams) {
return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: subject?.name || '', fullScore: subject?.fullScore || '', passScore: subject?.passScore || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' };
});
}
+ if (resource === 'admit_cards') {
+ const examId = cleanText(searchParams.get('examId'), 64);
+ const scoped = db.registrations.filter(item => item.admitCard && registrationInScope(db, user, item) && (!examId || item.examId === examId));
+ return admissionRowsForRegistrations(db, scoped);
+ }
return [];
}
@@ -539,15 +579,38 @@ function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
}
-function admitCardHtml(db, user, profile, registration) {
+function admitCardSection(db, user, profile, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item]));
+ const center = db.testCenters.find(item => item.id === registration.admitCard.centerId) || {};
+ const centerCode = registration.admitCard.centerCode || center.code || '';
+ const centerAddress = registration.admitCard.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' ');
const rows = subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
- return `| ${escapeHtml(subject.name)} | ${escapeHtml(subject.date)} | ${escapeHtml(subject.start)}—${escapeHtml(subject.end)} | ${escapeHtml(assignment.roomName || assignment.room || '待定')} | ${escapeHtml(assignment.seat || '—')} |
`;
+ const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
+ return `| ${escapeHtml(subject.name)} | ${escapeHtml(subject.date)} | ${escapeHtml(subject.start)}—${escapeHtml(subject.end)} | ${escapeHtml(assignment.examRoomCode || '待定')} | ${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}场地代码:${escapeHtml(assignment.roomCode || room.code || '—')} | ${escapeHtml(assignment.building || room.building || '—')}${escapeHtml(assignment.floor || room.floor || '楼层待定')} | ${escapeHtml(assignment.seat || '—')} |
`;
}).join('');
- return `${escapeHtml(exam.name)}准考证所有科目均安排在同一考点,但不同科目可能对应不同考场和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
`;
+ return `固定考点${escapeHtml(registration.admitCard.testCenter)}考点代码:${escapeHtml(centerCode || '—')}
考点详细地址${escapeHtml(centerAddress || '地址待公布')}
| 科目 | 日期 | 时间 | 考试考场序号 | 考场通用名称 / 场地代码 | 楼栋 / 楼层 | 座位号 |
${rows}
编号说明“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。
所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
`;
+}
+
+function admitCardsDocument(db, items, title) {
+ const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join('');
+ return `${escapeHtml(title)}${cards}`;
+}
+
+function admitCardHtml(db, user, profile, registration) {
+ const exam = db.exams.find(item => item.id === registration.examId);
+ return admitCardsDocument(db, [{ user, profile, registration }], `${exam.name}-${profile.name}-准考证`);
+}
+
+function admitCardsHtml(db, registrations, title) {
+ const items = registrations.map(registration => ({
+ registration,
+ user: db.users.find(item => item.id === registration.userId) || {},
+ profile: db.candidateProfiles.find(item => item.userId === registration.userId) || {}
+ }));
+ return admitCardsDocument(db, items, title);
}
const routeContext = {
@@ -588,8 +651,11 @@ const routeContext = {
logAction,
excelResourceNames,
excelRowsForResource,
+ admissionRowsForRegistrations,
+ centerMaterialRows,
importExcelResource,
admitCardHtml,
+ admitCardsHtml,
hashPassword,
verifyPassword,
randomBytes,
@@ -597,6 +663,7 @@ const routeContext = {
nowIso,
sessions,
buildWorkbook,
+ buildCenterMaterialsWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 132aee6..87c9513 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -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 ? `整场编排控制台
先预检容量与档案,再以一个事务生成或替换整套编排。
${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位 ` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。');
- const ruleCards = `${data.rules.map(rule => `号码规则${h(rule.name)}
${h(rule.description)}
${h(rule.example)}`).join('')}`;
- const planCards = data.plans.length ? `${data.plans.map(plan => `${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合
${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%
${plan.warnings.length ? `${plan.warnings.map(item => `- ${h(item)}
`).join('')}
` : '容量、档案与时间冲突检查通过
'}`).join('')}` : '';
+ const form = data.canArrange ? (selectedExam ? `整场编排控制台
先预检容量与档案,再以一个事务生成或替换整套编排。
${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位 ` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。')) : '';
+ const ruleCards = data.canArrange ? `${data.rules.map(rule => `号码规则${h(rule.name)}
${h(rule.description)}
${h(rule.example)}`).join('')}` : '';
+ const planCards = data.canArrange && data.plans.length ? `${data.plans.map(plan => `${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合
${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%
${plan.warnings.length ? `${plan.warnings.map(item => `- ${h(item)}
`).join('')}
` : '容量、档案与时间冲突检查通过
'}`).join('')}` : '';
+ const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0];
+ const exportPanel = exportExam ? `${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}
批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。
${approved.filter(item => item.admitCard).length} 份范围内准考证${data.canExportCenterMaterials ? '' : ''}
` : 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 `${h(subject.name)}${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)} 号` : '待编排'}`;
+ return `${h(subject.name)}${assignment ? `考试考场序号 ${h(assignment.examRoomCode)} · ${h(assignment.roomName || assignment.room)}(场地 ${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat)}` : '待编排'}`;
}).join('');
- return `${h((reg.candidate?.name || '?').slice(0,1))}${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')} | ${h(reg.exam.name)}${h(reg.exam.code)} | ${h(reg.admitCard?.number || '待编排')} | ${h(reg.admitCard?.testCenter || '—')} | ${subjects} | ${reg.admitCard ? `` : '—'} |
`;
+ return `${h((reg.candidate?.name || '?').slice(0,1))}${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')} | ${h(reg.exam.name)}${h(reg.exam.code)} | ${h(reg.admitCard?.number || '待编排')} | ${h(reg.admitCard?.testCenter || '—')}${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')} | ${subjects} | ${reg.admitCard ? `` : '—'} |
`;
}).join('');
- return `${form}${ruleCards}${planCards}`;
+ return `${form}${ruleCards}${planCards}${exportPanel}`;
}
function adminResults(data) {
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index 8831414..72ae3cf 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -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 `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '待定')} · ${h(assignment.seat || '—')} 号`;
+ return `${h(subject.name)}${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 || '—')}`;
}).join('');
- return `${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}${h(reg.exam.name)}
准考证号${h(reg.admitCard.number)}
- 固定考点
- ${h(reg.admitCard.testCenter)}
- 逐科安排
- ${subjectRows}
- 下载时间
- ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD下载后请使用 A4 纸打印
`;
+ return `${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}${h(reg.exam.name)}
准考证号${h(reg.admitCard.number)}
- 固定考点
- ${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
- 逐科详细安排
- ${subjectRows}
- 下载时间
- ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD下载后请使用 A4 纸横向打印
`;
}).join('')}` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态');
}
diff --git a/src/data/seed.mjs b/src/data/seed.mjs
index e5325e7..c7990a2 100644
--- a/src/data/seed.mjs
+++ b/src/data/seed.mjs
@@ -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' }
]
}
}
diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs
index 2c3c0f5..4a9f3ab 100644
--- a/src/database/mysql-adapter.mjs
+++ b/src/database/mysql-adapter.mjs
@@ -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);
diff --git a/src/database/schema.mjs b/src/database/schema.mjs
index e66cefb..2836ed4 100644
--- a/src/database/schema.mjs
+++ b/src/database/schema.mjs
@@ -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),
diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs
index f7bd908..3002c07 100644
--- a/src/database/sqlite-adapter.mjs
+++ b/src/database/sqlite-adapter.mjs
@@ -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');
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 06d6428..dd81102 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -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;
diff --git a/src/services/admission-arrangement.mjs b/src/services/admission-arrangement.mjs
index dc179ac..3b43a08 100644
--- a/src/services/admission-arrangement.mjs
+++ b/src/services/admission-arrangement.mjs
@@ -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)
};
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 1bde7dd..afea169 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import assert from 'node:assert/strict';
import ExcelJS from 'exceljs';
import { relationalTables } from '../database.mjs';
-import { buildWorkbook } from '../excel.mjs';
+import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
const root = resolve(process.cwd());
const port = 4182;
@@ -91,9 +91,15 @@ try {
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`).all().map(row => row.name);
+ const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version;
+ const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name);
+ const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name);
inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
+ assert.equal(schemaVersion, 10, '准考证详细位置快照应使用 v10 数据结构');
+ assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
+ assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
const publicHome = await anonymous.request('/api/public/home');
assert.equal(publicHome.response.status, 200);
@@ -406,8 +412,14 @@ try {
const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody });
const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId);
assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号');
+ assert.ok(generatedCard.centerCode && generatedCard.centerAddress, '准考证应包含考点代码和完整地址');
assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点');
assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位');
+ assert.ok(generatedCard.assignments.every(item => item.examRoomCode && item.roomName && item.roomCode && item.building && item.floor), '逐科安排必须同时保存考试考场序号、物理场地和楼栋楼层');
+ const codesByPhysicalRoom = Map.groupBy
+ ? Map.groupBy(generateAdmit.data.cards.flatMap(card => card.assignments), item => item.roomId)
+ : generateAdmit.data.cards.flatMap(card => card.assignments).reduce((groups, item) => groups.set(item.roomId, [...(groups.get(item.roomId) || []), item]), new Map());
+ assert.ok([...codesByPhysicalRoom.values()].every(items => new Set(items.map(item => item.examRoomCode)).size === 1), '同一场考试内,同一物理考场跨科目必须保持同一个考试考场序号');
assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用');
const candidateRegistrations = await candidate.request('/api/candidate/registrations');
@@ -417,8 +429,66 @@ try {
const downloadAdmit = await candidate.request(`/api/candidate/registrations/${registrationId}/admit-card`);
assert.equal(downloadAdmit.response.status, 200, '规定时间内应可下载准考证');
assert.match(downloadAdmit.data, /测试考生新名/);
+ assert.match(downloadAdmit.data, /考试考场序号/);
+ assert.match(downloadAdmit.data, /考场通用名称 \/ 场地代码/);
+ assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].building));
+ assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].floor));
assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/);
+ const schoolAdmitContext = await schoolAdmin.request('/api/admin/admission-arrangements');
+ assert.equal(schoolAdmitContext.response.status, 200, '校级管理员应有校内准考证入口');
+ assert.equal(schoolAdmitContext.data.canArrange, false, '校级管理员不能执行整场编排');
+ assert.ok(schoolAdmitContext.data.registrations.every(item => item.candidate.schoolId === 'school_hz1'), '校级准考证台账必须限制为本校考生');
+ const classAdmitContext = await classAdmin.request('/api/admin/admission-arrangements');
+ assert.equal(classAdmitContext.response.status, 200, '班级管理员应有本班准考证入口');
+ assert.ok(classAdmitContext.data.registrations.every(item => item.candidate.classId === 'class_hz1_302'), '班级准考证台账必须限制为本班考生');
+
+ const schoolBatchCards = await schoolAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`);
+ assert.equal(schoolBatchCards.response.status, 200, '校级管理员应可批量下载校内准考证');
+ assert.match(schoolBatchCards.response.headers.get('content-disposition') || '', /attachment/);
+ assert.match(schoolBatchCards.data, /考试考场序号/);
+ const classBatchCards = await classAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`);
+ assert.equal(classBatchCards.response.status, 200, '班级管理员应可批量下载本班准考证');
+ assert.match(classBatchCards.data, /测试考生新名/);
+
+ const classInfoExport = await classAdmin.request(`/api/admin/admission-exports/info?examId=${exam.id}`);
+ assert.equal(classInfoExport.response.status, 200, '班级管理员应可导出本班准考证信息台账');
+ const classInfoBook = new ExcelJS.Workbook();
+ await classInfoBook.xlsx.load(classInfoExport.data);
+ const classInfoSheet = classInfoBook.getWorksheet('准考证信息');
+ assert.ok(classInfoSheet, '准考证信息导出应有独立工作表');
+ assert.equal(classInfoSheet.rowCount, generatedCard.assignments.length + 2, '准考证信息应按考生逐科导出');
+ assert.equal(classInfoSheet.getCell('D3').value, '测试考生新名');
+ assert.equal(classInfoSheet.getCell('O3').value, generatedCard.assignments[0].examRoomCode, '信息台账应明确导出考试考场序号');
+ assert.equal(classInfoSheet.getCell('P3').value, generatedCard.assignments[0].roomName, '信息台账应另列考场通用名称');
+
+ const schoolMaterials = await schoolAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`);
+ assert.equal(schoolMaterials.response.status, 200, '校级管理员应可导出本校维护考点的现场物料');
+ const materialsBook = new ExcelJS.Workbook();
+ await materialsBook.xlsx.load(schoolMaterials.data);
+ assert.deepEqual(materialsBook.worksheets.map(sheet => sheet.name), ['桌贴', '门贴', '考场签名单'], '考点物料应包含桌贴、门贴和考场签名单三张表');
+ const deskStickerSheet = materialsBook.getWorksheet('桌贴');
+ assert.ok(deskStickerSheet.rowCount >= 8, '每张方形桌贴应占用完整标签卡片区域');
+ assert.equal(deskStickerSheet.pageSetup.orientation, 'portrait', '方形桌贴应使用 A4 纵向打印');
+ assert.equal(deskStickerSheet.getRow(1).height, 32.5, '桌贴应使用固定行高形成方形标签');
+ assert.equal(deskStickerSheet.getColumn(1).width, deskStickerSheet.getColumn(3).width, '桌贴卡片各列应等宽');
+ assert.ok(deskStickerSheet.model.merges.includes('A1:C1'), '第一张桌贴应跨三列形成完整方形卡片');
+ assert.match(String(deskStickerSheet.getCell('A3').value), /考试考场序号/);
+ assert.equal(deskStickerSheet.getCell('A4').text, generatedCard.assignments[0].examRoomCode, '桌贴应突出考试考场序号');
+ assert.match(String(deskStickerSheet.getCell('A5').value), new RegExp(generatedCard.assignments[0].seat), '桌贴应突出座位号');
+ assert.match(String(materialsBook.getWorksheet('门贴').getCell('A1').value), /考场门贴/);
+ assert.equal((await classAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`)).response.status, 403, '班级管理员不得导出考点级现场物料');
+
+ const paginationRows = Array.from({ length: 7 }, (_, index) => ({
+ examCode: 'EX-QA', examName: '桌贴分页测试', subjectName: '语文', subjectDate: '2026-10-01', subjectTime: '09:00—11:00',
+ centerCode: 'C01', centerName: '测试考点', examRoomCode: '001', roomName: '第一教室', roomCode: 'R01', building: '教学楼', floor: '1 层',
+ seat: String(index + 1).padStart(2, '0'), cardNumber: `CARD${index + 1}`, candidateName: `考生${index + 1}`, schoolName: '测试学校', className: '测试班'
+ }));
+ const paginationBook = new ExcelJS.Workbook();
+ await paginationBook.xlsx.load(await buildCenterMaterialsWorkbook(paginationRows, '分页测试'));
+ assert.equal(paginationBook.getWorksheet('桌贴').pageSetup.printArea, 'A1:G48', '超过六张桌贴时应扩展为两页固定打印区域');
+ assert.equal(paginationBook.getWorksheet('桌贴').getRow(32).height, 32.5, '第二页桌贴仍应保持相同方形标签高度');
+
const futureExamResponse = await admin.request('/api/admin/exams', {
method: 'POST',
body: {