准考证优化
This commit is contained in:
@@ -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
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+72
-5
@@ -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 `<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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
+71
-1
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user