准考证优化

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
+72 -5
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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 `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}${escapeHtml(subject.end)}</td><td>${escapeHtml(assignment.roomName || assignment.room || '待定')}</td><td>${escapeHtml(assignment.seat || '—')}</td></tr>`;
const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
return `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}${escapeHtml(subject.end)}</td><td class="room-no">${escapeHtml(assignment.examRoomCode || '待定')}</td><td><strong>${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}</strong><small>场地代码:${escapeHtml(assignment.roomCode || room.code || '—')}</small></td><td>${escapeHtml(assignment.building || room.building || '—')}<small>${escapeHtml(assignment.floor || room.floor || '楼层待定')}</small></td><td>${escapeHtml(assignment.seat || '—')}</td></tr>`;
}).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(exam.name)}准考证</title><style>body{font-family:"Microsoft YaHei",sans-serif;color:#182552;padding:32px;background:#f2f4f8}.card{max-width:800px;margin:auto;background:#fff;border:2px solid #182552;padding:32px;box-shadow:0 12px 35px #18255218}.head{display:flex;justify-content:space-between;border-bottom:3px double #182552;padding-bottom:18px}.brand{font-size:28px;font-weight:800}.code{font-family:monospace}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:24px 0}.meta div{border-bottom:1px solid #ddd;padding:8px}.meta span{color:#777;font-size:12px;margin-right:12px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd2df;padding:12px;text-align:left}th{background:#f3f5f9}.notice{margin-top:20px;padding:15px;background:#fff7e6;color:#704b12;font-size:13px}.footer{display:flex;justify-content:space-between;margin-top:22px;color:#777;font-size:12px}@media print{body{background:#fff;padding:0}.card{box-shadow:none}}</style></head><body><main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div class="code">${escapeHtml(registration.admitCard.number)}</div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>固定考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><div class="notice">所有科目均安排在同一考点,但不同科目可能对应不同考和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main></body></html>`;
return `<main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div><small>准考证号</small><div class="code">${escapeHtml(registration.admitCard.number)}</div></div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>报名号</span><strong>${escapeHtml(user.candidateNumber || registration.registrationNumber || '—')}</strong></div></section><section class="center"><div><span>固定考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong><small>考点代码:${escapeHtml(centerCode || '—')}</small></div><div><span>考点详细地址</span><strong>${escapeHtml(centerAddress || '地址待公布')}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><div class="explain"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class="notice">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main>`;
}
function admitCardsDocument(db, items, title) {
const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:"Microsoft YaHei",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}</style></head><body>${cards}</body></html>`;
}
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,