准考证优化

This commit is contained in:
biss committed 2026-07-20 14:18:37 +08:00
1 parent 9566542414
commit c5b18d0b15
13 files changed
+447 -57

No files matched your search

+10 -2
View File
@@ -111,6 +111,14 @@ document.addEventListener('click', async event => {
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content"><p>${h(notice.content).replace(/\n/g, '</p><p>')}</p></article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); 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 `<tr><td>${h(subject.name)}</td><td>${h(subject.date)} ${h(subject.start)}</td><td>${h(assignment.roomName || assignment.room || '待定')}</td><td>${h(assignment.seat || '—')}</td></tr>`;
return `<tr><td>${h(subject.name)}</td><td>${h(subject.date)} ${h(subject.start)}</td><td class="mono">${h(assignment.examRoomCode || '待定')}</td><td><strong>${h(assignment.roomName || assignment.room || '待定')}</strong><small>场地代码 ${h(assignment.roomCode || '—')}</small></td><td>${h(assignment.building || '楼栋待定')} · ${h(assignment.floor || '楼层待定')}</td><td>${h(assignment.seat || '—')}</td></tr>`;
}).join('');
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证编排明细</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><dl><div><dt>固定考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>生成时间</dt><dd>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><table><thead><tr><th>科目</th><th>时间</th><th>考场</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><p>考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证编排明细</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><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>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><table><thead><tr><th>科目</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><p>“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
}
window.addEventListener('hashchange', renderRoute);
+19 -13
View File
@@ -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));
+142
View File
@@ -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();
}
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff. Show more