diff --git a/app.js b/app.js index 77435ef..eee777f 100644 --- a/app.js +++ b/app.js @@ -111,6 +111,14 @@ document.addEventListener('click', async event => { setModal(`

${h(notice.content).replace(/\n/g, '

')}

`); return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } + if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) { + event.preventDefault(); + const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value; + if (!examId) return toast('请选择考试', '没有可导出的考试范围'); + const type = { 'batch-admit-download': 'admit-cards', 'admit-info-export': 'info', 'center-materials-export': 'center-materials' }[action]; + window.location.href = `/api/admin/admission-exports/${type}?examId=${encodeURIComponent(examId)}`; + return; + } if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); if (action === 'new-admin') return openAdminForm(); @@ -562,9 +570,9 @@ function openAdmitPreview(reg) { const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item])); const rows = reg.subjects.map(subject => { const assignment = assignments.get(subject.id) || {}; - return `${h(subject.name)}${h(subject.date)} ${h(subject.start)}${h(assignment.roomName || assignment.room || '待定')}${h(assignment.seat || '—')}`; + return `${h(subject.name)}${h(subject.date)} ${h(subject.start)}${h(assignment.examRoomCode || '待定')}${h(assignment.roomName || assignment.room || '待定')}场地代码 ${h(assignment.roomCode || '—')}${h(assignment.building || '楼栋待定')} · ${h(assignment.floor || '楼层待定')}${h(assignment.seat || '—')}`; }).join(''); - setModal(`
${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}
${rows}
科目时间考场座位

考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); + setModal(`
${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}
${rows}
科目时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位

“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); } window.addEventListener('hashchange', renderRoute); diff --git a/database.mjs b/database.mjs index db59193..8787f56 100644 --- a/database.mjs +++ b/database.mjs @@ -57,7 +57,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 9, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 10, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); @@ -219,16 +219,17 @@ function buildSeedOperations(state) { const card = registration.admitCard; add( `INSERT INTO admit_cards ( - registration_id, plan_id, card_number, center_id, test_center, generated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, - registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, card.generatedAt + registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, + card.centerCode || '', card.centerAddress || '', card.generatedAt ); for (const assignment of card.assignments || []) add( `INSERT INTO admit_card_subjects ( - registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room, - assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature || '' + assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature || '' ); } @@ -374,6 +375,8 @@ function stateFromRows(rows) { room: row.room, roomCode: row.room_code, examRoomCode: row.exam_room_code, + building: row.building || '', + floor: row.floor || '', seat: row.seat, subjectSignature: row.subject_signature || '' }); @@ -390,6 +393,8 @@ function stateFromRows(rows) { number: row.card_number, centerId: row.center_id, testCenter: row.test_center, + centerCode: row.center_code || '', + centerAddress: row.center_address || '', room: primary.roomName || '', seat: primary.seat || '', assignments, @@ -1265,16 +1270,17 @@ function createRepository({ client, location, read, transaction, close }) { for (const card of cards) { operations.push(operation( `INSERT INTO admit_cards ( - registration_id, plan_id, card_number, center_id, test_center, generated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, - card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, card.generatedAt + registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, + card.centerCode || '', card.centerAddress || '', card.generatedAt )); for (const assignment of card.assignments) operations.push(operation( `INSERT INTO admit_card_subjects ( - registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName, - assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature + assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature )); } operations.push(auditOperation(log)); diff --git a/excel.mjs b/excel.mjs index ccfbb95..fad2da3 100644 --- a/excel.mjs +++ b/excel.mjs @@ -78,6 +78,18 @@ const resourceSpecs = { ], validations: { published: ['发布', '不发布'] }, numberColumns: ['score'] + }, + admit_cards: { + title: '准考证信息台账', sheet: '准考证信息', + columns: [ + ['schoolName', '考生学校', 24, ''], ['className', '班级', 20, ''], + ['candidateNumber', '报名号', 24, ''], ['candidateName', '姓名', 14, ''], ['idNumber', '证件号码', 22, ''], + ['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''], ['cardNumber', '准考证号', 22, ''], + ['centerCode', '考点代码', 16, ''], ['centerName', '考点名称', 26, ''], ['centerAddress', '考点详细地址', 38, ''], + ['subjectName', '科目', 14, ''], ['subjectDate', '日期', 14, ''], ['subjectTime', '时间', 16, ''], + ['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''], + ['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, ''] + ] } }; @@ -173,3 +185,133 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } }; return workbook.xlsx.writeBuffer(); } + +function addOperationalSheet(workbook, name, title, columns, rows, { landscape = true } = {}) { + const sheet = workbook.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] }); + sheet.columns = columns.map(([key, , width]) => ({ key, width })); + sheet.mergeCells(1, 1, 1, columns.length); + const titleCell = sheet.getCell(1, 1); + titleCell.value = title; + titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } }; + titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } }; + titleCell.alignment = { vertical: 'middle', horizontal: 'left' }; + sheet.getRow(1).height = 34; + const header = sheet.getRow(2); + header.values = columns.map(([, label]) => label); + header.height = 28; + header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } }; + header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } }; + header.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + rows.forEach((item, index) => { + const row = sheet.addRow(Object.fromEntries(columns.map(([key]) => [key, item[key] ?? '']))); + row.height = 28; + row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } }; + row.alignment = { vertical: 'middle', wrapText: true }; + if (index % 2 === 1) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F7F8' } }; + row.eachCell(cell => { cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; }); + }); + sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: columns.length } }; + sheet.pageSetup = { orientation: landscape ? 'landscape' : 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: .25, right: .25, top: .4, bottom: .4, header: .2, footer: .2 } }; + sheet.printTitlesRow = '1:2'; + return sheet; +} + +function addDeskStickerSheet(workbook, rows) { + const sheet = workbook.addWorksheet('桌贴', { views: [{ showGridLines: false, zoomScale: 85 }] }); + const labelColumns = [14.5, 14.5, 14.5, 3, 14.5, 14.5, 14.5]; + labelColumns.forEach((width, index) => { sheet.getColumn(index + 1).width = width; }); + sheet.pageSetup = { + orientation: 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, + margins: { left: .25, right: .25, top: .25, bottom: .25, header: 0, footer: 0 }, + horizontalCentered: true, verticalCentered: false + }; + sheet.headerFooter = { oddFooter: '&C第 &P / &N 页' }; + const rowsPerLabel = 8; + const labelsPerPage = 6; + const rowsPerPage = rowsPerLabel * 3; + const borderColor = { argb: 'FF173F60' }; + const paleBlue = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF1F5' } }; + const paleGold = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF1C7' } }; + const mergeLine = (row, startColumn) => { + sheet.mergeCells(row, startColumn, row, startColumn + 2); + return sheet.getCell(row, startColumn); + }; + const styleLine = (cell, { size = 10, bold = false, color = 'FF243B4A', fill = null } = {}) => { + cell.font = { name: '微软雅黑', size, bold, color: { argb: color } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true, shrinkToFit: true }; + if (fill) cell.fill = fill; + }; + + rows.forEach((item, index) => { + const page = Math.floor(index / labelsPerPage); + const slot = index % labelsPerPage; + const startRow = page * rowsPerPage + Math.floor(slot / 2) * rowsPerLabel + 1; + const startColumn = slot % 2 === 0 ? 1 : 5; + for (let row = startRow; row < startRow + rowsPerLabel; row += 1) sheet.getRow(row).height = 32.5; + const cells = Array.from({ length: rowsPerLabel }, (_, offset) => mergeLine(startRow + offset, startColumn)); + cells[0].value = item.examName; + styleLine(cells[0], { size: 11, bold: true, color: 'FFFFFFFF', fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } } }); + cells[1].value = `${item.subjectName}|${item.subjectDate} ${item.subjectTime}`; + styleLine(cells[1], { size: 9, bold: true, fill: paleBlue }); + cells[2].value = '考试考场序号'; + styleLine(cells[2], { size: 9, bold: true, color: 'FF2C7080' }); + cells[3].value = { richText: [{ text: String(item.examRoomCode || '') }] }; + cells[3].numFmt = '@'; + styleLine(cells[3], { size: 25, bold: true, color: 'FF173F60' }); + cells[4].value = `座位号 ${item.seat}`; + styleLine(cells[4], { size: 20, bold: true, color: 'FF8A4B08', fill: paleGold }); + cells[5].value = `${item.roomName}(场地 ${item.roomCode})|${item.building} ${item.floor}`; + styleLine(cells[5], { size: 9, bold: true }); + cells[6].value = `${item.candidateName}|${item.schoolName} ${item.className}`; + styleLine(cells[6], { size: 10, bold: true, fill: paleBlue }); + cells[7].value = `${item.centerName}|准考证号 ${item.cardNumber}`; + styleLine(cells[7], { size: 8, color: 'FF526576' }); + + for (let row = startRow; row < startRow + rowsPerLabel; row += 1) { + for (let column = startColumn; column < startColumn + 3; column += 1) { + const cell = sheet.getCell(row, column); + cell.border = { + top: { style: row === startRow ? 'medium' : 'hair', color: borderColor }, + bottom: { style: row === startRow + rowsPerLabel - 1 ? 'medium' : 'hair', color: borderColor }, + left: { style: column === startColumn ? 'medium' : 'hair', color: borderColor }, + right: { style: column === startColumn + 2 ? 'medium' : 'hair', color: borderColor } + }; + } + } + if ((index + 1) % labelsPerPage === 0 && index + 1 < rows.length) sheet.getRow(startRow + rowsPerLabel - 1).addPageBreak(); + }); + if (rows.length) sheet.pageSetup.printArea = `A1:G${Math.ceil(rows.length / labelsPerPage) * rowsPerPage}`; + return sheet; +} + +export async function buildCenterMaterialsWorkbook(rows, subtitle = '') { + const workbook = new ExcelJS.Workbook(); + workbook.creator = '衡准考试信息管理系统'; + workbook.created = new Date(); + const sorted = [...rows].sort((a, b) => String(a.centerCode).localeCompare(String(b.centerCode)) + || String(a.subjectDate).localeCompare(String(b.subjectDate)) || String(a.subjectTime).localeCompare(String(b.subjectTime)) + || String(a.examRoomCode).localeCompare(String(b.examRoomCode)) || String(a.seat).localeCompare(String(b.seat))); + const baseTitle = subtitle ? `|${subtitle}` : ''; + addDeskStickerSheet(workbook, sorted); + + const doorMap = new Map(); + for (const item of sorted) { + const key = [item.examCode, item.subjectName, item.centerCode, item.examRoomCode, item.roomCode].join('|'); + const current = doorMap.get(key) || { ...item, candidateCount: 0 }; + current.candidateCount += 1; + doorMap.set(key, current); + } + addOperationalSheet(workbook, '门贴', `考场门贴${baseTitle}`, [ + ['examName', '考试', 28], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16], + ['centerName', '考点', 24], ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], + ['roomCode', '场地代码', 14], ['building', '楼栋', 16], ['floor', '楼层', 10], ['candidateCount', '人数', 10] + ], [...doorMap.values()]); + + addOperationalSheet(workbook, '考场签名单', `考场考生签名单${baseTitle}`, [ + ['examName', '考试', 26], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16], + ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], ['building', '楼栋', 16], ['floor', '楼层', 10], + ['seat', '座位号', 10], ['cardNumber', '准考证号', 22], ['candidateName', '姓名', 14], + ['schoolName', '考生学校', 24], ['className', '班级', 18], ['signature', '考生签名', 18] + ], sorted.map(item => ({ ...item, signature: '' }))); + return workbook.xlsx.writeBuffer(); +} diff --git a/server.mjs b/server.mjs index af92bbc..98946eb 100644 --- a/server.mjs +++ b/server.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { extname, join, normalize, resolve } from 'node:path'; import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; import { createDatabase } from './database.mjs'; -import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; +import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; import { createAdminRoutes } from './src/routes/admin.routes.mjs'; import { createCandidateRoutes } from './src/routes/candidate.routes.mjs'; import { createAuthRoutes } from './src/routes/auth.routes.mjs'; @@ -352,9 +352,44 @@ function logAction(db, user, action, detail) { const excelResourceNames = { classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', - account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账' + account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账', + admit_cards: '准考证信息台账' }; +function admissionRowsForRegistrations(db, registrations) { + return registrations.flatMap(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {}; + const account = db.users.find(item => item.id === registration.userId) || {}; + const exam = db.exams.find(item => item.id === registration.examId) || { subjects: [] }; + const school = db.schools.find(item => item.id === profile.schoolId); + const schoolClass = db.classes.find(item => item.id === profile.classId); + const card = registration.admitCard; + if (!card) return []; + const center = db.testCenters.find(item => item.id === card.centerId) || {}; + return (card.assignments || []).map(assignment => { + const subject = exam.subjects.find(item => item.id === assignment.subjectId) || {}; + const room = db.testRooms.find(item => item.id === assignment.roomId) || {}; + return { + schoolName: school?.name || profile.school || '', className: schoolClass?.name || profile.grade || '', + candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', idNumber: profile.idNumber || '', + examCode: exam.code || '', examName: exam.name || '', cardNumber: card.number || '', + centerCode: card.centerCode || center.code || '', centerName: card.testCenter || center.name || '', + centerAddress: card.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '), + subjectName: subject.name || '', subjectDate: subject.date || '', subjectTime: [subject.start, subject.end].filter(Boolean).join('—'), + examRoomCode: assignment.examRoomCode || '', roomName: assignment.roomName || assignment.room || room.name || '', + roomCode: assignment.roomCode || room.code || '', building: assignment.building || room.building || '', + floor: assignment.floor || room.floor || '', seat: assignment.seat || '' + }; + }); + }); +} + +function centerMaterialRows(db, schoolId, examId) { + const centerIds = new Set(db.testCenters.filter(item => item.schoolId === schoolId).map(item => item.id)); + const registrations = db.registrations.filter(item => item.admitCard && item.examId === examId && centerIds.has(item.admitCard.centerId)); + return admissionRowsForRegistrations(db, registrations); +} + function excelRowsForResource(db, user, resource, searchParams) { const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId); if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({ @@ -405,6 +440,11 @@ function excelRowsForResource(db, user, resource, searchParams) { return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: subject?.name || '', fullScore: subject?.fullScore || '', passScore: subject?.passScore || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' }; }); } + if (resource === 'admit_cards') { + const examId = cleanText(searchParams.get('examId'), 64); + const scoped = db.registrations.filter(item => item.admitCard && registrationInScope(db, user, item) && (!examId || item.examId === examId)); + return admissionRowsForRegistrations(db, scoped); + } return []; } @@ -539,15 +579,38 @@ function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } -function admitCardHtml(db, user, profile, registration) { +function admitCardSection(db, user, profile, registration) { const exam = db.exams.find(item => item.id === registration.examId); const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item])); + const center = db.testCenters.find(item => item.id === registration.admitCard.centerId) || {}; + const centerCode = registration.admitCard.centerCode || center.code || ''; + const centerAddress = registration.admitCard.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '); const rows = subjects.map(subject => { const assignment = assignments.get(subject.id) || {}; - return `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(assignment.roomName || assignment.room || '待定')}${escapeHtml(assignment.seat || '—')}`; + const room = db.testRooms.find(item => item.id === assignment.roomId) || {}; + return `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(assignment.examRoomCode || '待定')}${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}场地代码:${escapeHtml(assignment.roomCode || room.code || '—')}${escapeHtml(assignment.building || room.building || '—')}${escapeHtml(assignment.floor || room.floor || '楼层待定')}${escapeHtml(assignment.seat || '—')}`; }).join(''); - return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
固定考点${escapeHtml(registration.admitCard.testCenter)}
${rows}
科目日期时间考场座位
所有科目均安排在同一考点,但不同科目可能对应不同考场和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
`; + return `
衡准 · 准考证

${escapeHtml(exam.name)}

准考证号
${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
报名号${escapeHtml(user.candidateNumber || registration.registrationNumber || '—')}
固定考点${escapeHtml(registration.admitCard.testCenter)}考点代码:${escapeHtml(centerCode || '—')}
考点详细地址${escapeHtml(centerAddress || '地址待公布')}
${rows}
科目日期时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位号
编号说明“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。
所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
`; +} + +function admitCardsDocument(db, items, title) { + const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join(''); + return `${escapeHtml(title)}${cards}`; +} + +function admitCardHtml(db, user, profile, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + return admitCardsDocument(db, [{ user, profile, registration }], `${exam.name}-${profile.name}-准考证`); +} + +function admitCardsHtml(db, registrations, title) { + const items = registrations.map(registration => ({ + registration, + user: db.users.find(item => item.id === registration.userId) || {}, + profile: db.candidateProfiles.find(item => item.userId === registration.userId) || {} + })); + return admitCardsDocument(db, items, title); } const routeContext = { @@ -588,8 +651,11 @@ const routeContext = { logAction, excelResourceNames, excelRowsForResource, + admissionRowsForRegistrations, + centerMaterialRows, importExcelResource, admitCardHtml, + admitCardsHtml, hashPassword, verifyPassword, randomBytes, @@ -597,6 +663,7 @@ const routeContext = { nowIso, sessions, buildWorkbook, + buildCenterMaterialsWorkbook, hasExcelResource, parseWorkbook, adminLevelNames, diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 132aee6..87c9513 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -26,7 +26,7 @@ export function createAdminViews(context) { const meta = { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], - notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证编排', '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。'], + notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], @@ -92,18 +92,20 @@ export function createAdminViews(context) { const selectedExam = data.exams.find(exam => exam.approvedCount > 0) || data.exams[0]; const selectedPlan = selectedExam?.plan; const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code; - const form = selectedExam ? `

整场编排控制台

先预检容量与档案,再以一个事务生成或替换整套编排。

${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位
` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。'); - const ruleCards = `
${data.rules.map(rule => `
号码规则

${h(rule.name)}

${h(rule.description)}

${h(rule.example)}
`).join('')}
`; - const planCards = data.plans.length ? `
${data.plans.map(plan => `
${h(scopeName(plan.mixingScope))}

${h(plan.examName)}

${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合

${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%

${plan.warnings.length ? `` : '

容量、档案与时间冲突检查通过

'}
`).join('')}
` : ''; + const form = data.canArrange ? (selectedExam ? `

整场编排控制台

先预检容量与档案,再以一个事务生成或替换整套编排。

${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位
` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。')) : ''; + const ruleCards = data.canArrange ? `
${data.rules.map(rule => `
号码规则

${h(rule.name)}

${h(rule.description)}

${h(rule.example)}
`).join('')}
` : ''; + const planCards = data.canArrange && data.plans.length ? `
${data.plans.map(plan => `
${h(scopeName(plan.mixingScope))}

${h(plan.examName)}

${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合

${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%

${plan.warnings.length ? `` : '

容量、档案与时间冲突检查通过

'}
`).join('')}
` : ''; + const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0]; + const exportPanel = exportExam ? `

${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}

批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。

${approved.filter(item => item.admitCard).length} 份范围内准考证
${data.canExportCenterMaterials ? '' : ''}
` : emptyState('当前范围还没有考试报名', '待报名审核通过并完成整场编排后,可在这里批量下载。'); const rows = approved.map(reg => { const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item])); const subjects = reg.subjects.map(subject => { const assignment = assignments.get(subject.id); - return `${h(subject.name)}${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)} 号` : '待编排'}`; + return `${h(subject.name)}${assignment ? `考试考场序号 ${h(assignment.examRoomCode)} · ${h(assignment.roomName || assignment.room)}(场地 ${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat)}` : '待编排'}`; }).join(''); - return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}
${subjects}
${reg.admitCard ? `` : '—'}`; + return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}
${subjects}
${reg.admitCard ? `` : '—'}`; }).join(''); - return `${form}${ruleCards}${planCards}

同一考生所有科目固定在一个考点;考场和座位按科目分别展示。

${rows || ''}
考生考试准考证号固定考点分科考场 / 座位操作
暂无已通过的考试报名
`; + return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
`; } function adminResults(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 8831414..72ae3cf 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -25,9 +25,9 @@ export function createCandidateViews(context) { function adminNavForUser() { const level = state.user?.adminLevel || 'super'; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; - if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]]; + if (level === 'class') return [core[0], core[1], core[2], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[3]]; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; - if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3]]; return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3]]; } @@ -137,9 +137,9 @@ export function createCandidateViews(context) { const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item])); const subjectRows = reg.subjects.map(subject => { const assignment = assignments.get(subject.id) || {}; - return `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '待定')} · ${h(assignment.seat || '—')} 号`; + return `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · 考试考场序号 ${h(assignment.examRoomCode || '待定')} · ${h(assignment.roomName || assignment.room || '场地待定')}(${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat || '—')}`; }).join(''); - return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}
逐科安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; + return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
逐科详细安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸横向打印
`; }).join('')}` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态'); } diff --git a/src/data/seed.mjs b/src/data/seed.mjs index e5325e7..c7990a2 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 9, createdAt: nowIso() }, + meta: { version: 10, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', @@ -77,13 +77,13 @@ export function createSeedDatabase({ nowIso, hashPassword }) { id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', admitCard: { - planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', + planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', centerCode: 'HZ03-C01', centerAddress: '江苏省 连云港市 连云区 育才路 16 号', room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [ - { subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, - { subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '003', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, - { subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '006', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, - { subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '009', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, - { subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '011', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' } + { subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, + { subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, + { subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, + { subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }, + { subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' } ] } } diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 2c3c0f5..4a9f3ab 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -88,6 +88,10 @@ export function createMysqlAdapter(context) { 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL', 'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', 'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', + "ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_code VARCHAR(40) NOT NULL DEFAULT ''", + "ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_address VARCHAR(500) NOT NULL DEFAULT ''", + "ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS building VARCHAR(120) NOT NULL DEFAULT ''", + "ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS floor VARCHAR(80) NOT NULL DEFAULT ''", "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" ]; for (const statement of mysqlColumnMigrations) await pool.execute(statement); @@ -119,6 +123,17 @@ export function createMysqlAdapter(context) { metadataRows[0].schema_version = 9; metadataRows[0].app_version = 9; } + if (Number(metadataRows[0]?.schema_version || 1) < 10) { + await pool.execute(`UPDATE admit_cards card LEFT JOIN test_centers center ON center.id = card.center_id SET + card.center_code = COALESCE(center.code, card.center_code), + card.center_address = COALESCE(CONCAT_WS(' ', center.province_name, center.city_name, center.district_name, center.address), card.center_address)`); + await pool.execute(`UPDATE admit_card_subjects assignment LEFT JOIN test_rooms room ON room.id = assignment.room_id SET + assignment.building = COALESCE(room.building, assignment.building), + assignment.floor = COALESCE(room.floor, assignment.floor)`); + await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1'); + metadataRows[0].schema_version = 10; + metadataRows[0].app_version = 10; + } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); @@ -290,6 +305,9 @@ export function createMysqlAdapter(context) { if (Number(metadataRows[0]?.app_version || 1) < 7) { await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1'); } + if (Number(metadataRows[0]?.schema_version || 1) < 10) { + await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1'); + } } if (!existing.length) { const initialState = seed(); @@ -298,7 +316,7 @@ export function createMysqlAdapter(context) { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 9, ?, ?, ?) + VALUES (1, 10, ?, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index e66cefb..2836ed4 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -183,6 +183,8 @@ export const sqliteSchema = ` card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, test_center TEXT NOT NULL, + center_code TEXT NOT NULL, + center_address TEXT NOT NULL, generated_at TEXT NOT NULL ) STRICT; @@ -193,6 +195,8 @@ export const sqliteSchema = ` room TEXT NOT NULL, room_code TEXT NOT NULL, exam_room_code TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL, PRIMARY KEY (registration_id, subject_id), @@ -687,6 +691,8 @@ export const mysqlSchema = [ card_number VARCHAR(100) NOT NULL, center_id VARCHAR(64) NULL, test_center VARCHAR(200) NOT NULL, + center_code VARCHAR(40) NOT NULL, + center_address VARCHAR(500) NOT NULL, generated_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id), UNIQUE KEY uq_admit_cards_number (card_number), @@ -701,6 +707,8 @@ export const mysqlSchema = [ room VARCHAR(120) NOT NULL, room_code VARCHAR(40) NOT NULL, exam_room_code VARCHAR(20) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(80) NOT NULL, seat VARCHAR(30) NOT NULL, subject_signature VARCHAR(1000) NOT NULL, PRIMARY KEY (registration_id, subject_id), diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index f7bd908..3002c07 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -57,6 +57,12 @@ export function createSqliteAdapter(context) { ]); ensureColumns('test_rooms', [['seat_plan', 'TEXT']]); ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]); + ensureColumns('admit_cards', [ + ['center_code', "TEXT NOT NULL DEFAULT ''"], ['center_address', "TEXT NOT NULL DEFAULT ''"] + ]); + ensureColumns('admit_card_subjects', [ + ['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"] + ]); if (tableExists('workflow_definitions')) { const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; if (!definitionSql.includes('candidate_account_batch')) { @@ -144,13 +150,13 @@ export function createSqliteAdapter(context) { registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE, card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, - test_center TEXT NOT NULL, generated_at TEXT NOT NULL + test_center TEXT NOT NULL, center_code TEXT NOT NULL, center_address TEXT NOT NULL, generated_at TEXT NOT NULL ) STRICT; CREATE TABLE admit_card_subjects ( registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL, - exam_room_code TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL, + exam_room_code TEXT NOT NULL, building TEXT NOT NULL, floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL, PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat) ) STRICT; CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at); @@ -172,6 +178,22 @@ export function createSqliteAdapter(context) { try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {} } } + if (existingSystem && Number(existingSystem.schema_version || 1) < 10) { + connection.prepare(` + UPDATE admit_cards SET + center_code = COALESCE((SELECT code FROM test_centers WHERE id = admit_cards.center_id), center_code), + center_address = COALESCE((SELECT trim( + COALESCE(province_name, '') || ' ' || COALESCE(city_name, '') || ' ' || + COALESCE(district_name, '') || ' ' || COALESCE(address, '') + ) FROM test_centers WHERE id = admit_cards.center_id), center_address) + `).run(); + connection.prepare(` + UPDATE admit_card_subjects SET + building = COALESCE((SELECT building FROM test_rooms WHERE id = admit_card_subjects.room_id), building), + floor = COALESCE((SELECT floor FROM test_rooms WHERE id = admit_card_subjects.room_id), floor) + `).run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run(); + } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); connection.exec('BEGIN IMMEDIATE'); @@ -322,6 +344,9 @@ export function createSqliteAdapter(context) { if (existingSystem && Number(existingSystem.app_version || 1) < 7) { connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run(); } + if (existingSystem && Number(existingSystem.schema_version || 1) < 10) { + connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run(); + } if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { const initialState = seed(); @@ -329,7 +354,7 @@ export function createSqliteAdapter(context) { try { connection.prepare(` INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 9, ?, ?, ?) + VALUES (1, 10, ?, ?, ?) `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 06d6428..dd81102 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -37,8 +37,11 @@ export function createAdminRoutes(context) { logAction, excelResourceNames, excelRowsForResource, + admissionRowsForRegistrations, + centerMaterialRows, importExcelResource, admitCardHtml, + admitCardsHtml, hashPassword, verifyPassword, randomBytes, @@ -46,6 +49,7 @@ export function createAdminRoutes(context) { nowIso, sessions, buildWorkbook, + buildCenterMaterialsWorkbook, hasExcelResource, parseWorkbook, adminLevelNames, @@ -593,8 +597,9 @@ export function createAdminRoutes(context) { return sendJson(response, 200, { ok: true, registrations }); } if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') { - if (!requirePermission(user, response, '*')) return true; - const registrations = db.registrations.filter(item => item.status === 'approved').map(registration => { + if (!requirePermission(user, response, 'registrations.read')) return true; + const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)); + const registrations = scopedRegistrations.map(registration => { const profile = db.candidateProfiles.find(item => item.userId === registration.userId); return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null }; }); @@ -604,26 +609,61 @@ export function createAdminRoutes(context) { ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '', mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope })); - const exams = db.exams.map(exam => ({ + const visibleExams = user.adminLevel === 'super' ? db.exams : db.exams.filter(exam => scopedRegistrations.some(item => item.examId === exam.id)); + const exams = visibleExams.map(exam => ({ ...publicExam(exam), - approvedCount: db.registrations.filter(item => item.examId === exam.id && item.status === 'approved').length, - arrangedCount: db.registrations.filter(item => item.examId === exam.id && item.admitCard).length, + approvedCount: scopedRegistrations.filter(item => item.examId === exam.id).length, + arrangedCount: scopedRegistrations.filter(item => item.examId === exam.id && item.admitCard).length, plan: plans.find(item => item.examId === exam.id) || null })); + const centerScope = user.adminLevel === 'super' + ? db.testCenters + : user.adminLevel === 'school' ? db.testCenters.filter(item => item.schoolId === user.schoolId) : []; return sendJson(response, 200, { ok: true, + canArrange: user.adminLevel === 'super', + canExportCenterMaterials: user.adminLevel === 'school', + scopeLabel: adminScopeLabel(db, user), exams, registrations, - plans, - rules: db.admissionNumberRules.filter(item => item.active), - mixingScopes: admissionMixingScopes, - centers: db.testCenters.filter(item => item.status === 'active').map(center => ({ + plans: user.adminLevel === 'super' ? plans : [], + rules: user.adminLevel === 'super' ? db.admissionNumberRules.filter(item => item.active) : [], + mixingScopes: user.adminLevel === 'super' ? admissionMixingScopes : [], + centers: centerScope.filter(item => item.status === 'active').map(center => ({ ...center, roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length, capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0) })) }); } + const admissionExportMatch = pathname.match(/^\/api\/admin\/admission-exports\/(admit-cards|info|center-materials)$/); + if (request.method === 'GET' && admissionExportMatch) { + if (!requirePermission(user, response, 'registrations.read')) return true; + const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); + const examId = cleanText(requestUrl.searchParams.get('examId'), 64); + const exam = db.exams.find(item => item.id === examId); + if (!exam) return sendError(response, 404, '请选择有效考试'); + const type = admissionExportMatch[1]; + if (type === 'center-materials') { + if (user.adminLevel !== 'school') return sendError(response, 403, '考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出'); + const rows = centerMaterialRows(db, user.schoolId, exam.id); + if (!rows.length) return sendError(response, 404, '本校维护考点暂无该考试的已编排考生'); + const buffer = Buffer.from(await buildCenterMaterialsWorkbook(rows, `${exam.name}|${adminScopeLabel(db, user)}`)); + return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-考点桌贴门贴签名单.xlsx`); + } + const scoped = db.registrations.filter(item => item.examId === exam.id && item.admitCard && registrationInScope(db, user, item)); + if (!scoped.length) return sendError(response, 404, '当前范围暂无已生成的准考证'); + if (type === 'admit-cards') { + const html = admitCardsHtml(db, scoped, `${exam.name}-${adminScopeLabel(db, user)}-准考证`); + const filename = encodeURIComponent(`${exam.name}-${adminScopeLabel(db, user)}-准考证批量打印.html`); + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' }); + response.end(html); + return true; + } + const rows = admissionRowsForRegistrations(db, scoped); + const buffer = Buffer.from(await buildWorkbook('admit_cards', rows, { subtitle: `${exam.name}|${adminScopeLabel(db, user)}` })); + return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-准考证信息.xlsx`); + } const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/); if (request.method === 'PATCH' && registrationMatch) { if (!requirePermission(user, response, 'registrations.review')) return true; diff --git a/src/services/admission-arrangement.mjs b/src/services/admission-arrangement.mjs index dc179ac..3b43a08 100644 --- a/src/services/admission-arrangement.mjs +++ b/src/services/admission-arrangement.mjs @@ -208,23 +208,25 @@ export function buildAdmissionArrangement(db, options) { roomId: room.id, roomName: room.name, roomCode: room.code, + building: room.building || '', + floor: room.floor || '', examRoomCode: '', seat: String(seatNumber).padStart(2, '0'), subjectSignature: candidate.signature }; assignments.get(candidate.id).push(assignment); - roomUses.push({ subjectId: subject.id, subjectOrder: subjectOrder.get(subject.id), centerCode: center.code, roomCode: room.code, roomId: room.id }); + roomUses.push({ centerCode: center.code, roomCode: room.code, roomId: room.id }); seatIndex += 1; } } } - const examRoomCodes = new Map([...new Map(roomUses.map(item => [`${item.subjectId}:${item.roomId}`, item])).values()] - .sort((left, right) => left.subjectOrder - right.subjectOrder || left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode)) - .map((item, index) => [`${item.subjectId}:${item.roomId}`, String(index + 1).padStart(3, '0')])); + const examRoomCodes = new Map([...new Map(roomUses.map(item => [item.roomId, item])).values()] + .sort((left, right) => left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode) || left.roomId.localeCompare(right.roomId)) + .map((item, index) => [item.roomId, String(index + 1).padStart(3, '0')])); for (const subjectAssignments of assignments.values()) { subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId)); - subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(`${item.subjectId}:${item.roomId}`); }); + subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(item.roomId); }); } const sequenceCounters = new Map(); @@ -252,6 +254,8 @@ export function buildAdmissionArrangement(db, options) { number, centerId: center.id, testCenter: center.name, + centerCode: center.code || '', + centerAddress: [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '), generatedAt: options.generatedAt, assignments: assignments.get(candidate.id) }; diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 1bde7dd..afea169 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import assert from 'node:assert/strict'; import ExcelJS from 'exceljs'; import { relationalTables } from '../database.mjs'; -import { buildWorkbook } from '../excel.mjs'; +import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs'; const root = resolve(process.cwd()); const port = 4182; @@ -91,9 +91,15 @@ try { WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name `).all().map(row => row.name); + const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version; + const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name); + const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name); inspector.close(); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); + assert.equal(schemaVersion, 10, '准考证详细位置快照应使用 v10 数据结构'); + assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); + assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); const publicHome = await anonymous.request('/api/public/home'); assert.equal(publicHome.response.status, 200); @@ -406,8 +412,14 @@ try { const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody }); const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId); assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号'); + assert.ok(generatedCard.centerCode && generatedCard.centerAddress, '准考证应包含考点代码和完整地址'); assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点'); assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位'); + assert.ok(generatedCard.assignments.every(item => item.examRoomCode && item.roomName && item.roomCode && item.building && item.floor), '逐科安排必须同时保存考试考场序号、物理场地和楼栋楼层'); + const codesByPhysicalRoom = Map.groupBy + ? Map.groupBy(generateAdmit.data.cards.flatMap(card => card.assignments), item => item.roomId) + : generateAdmit.data.cards.flatMap(card => card.assignments).reduce((groups, item) => groups.set(item.roomId, [...(groups.get(item.roomId) || []), item]), new Map()); + assert.ok([...codesByPhysicalRoom.values()].every(items => new Set(items.map(item => item.examRoomCode)).size === 1), '同一场考试内,同一物理考场跨科目必须保持同一个考试考场序号'); assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用'); const candidateRegistrations = await candidate.request('/api/candidate/registrations'); @@ -417,8 +429,66 @@ try { const downloadAdmit = await candidate.request(`/api/candidate/registrations/${registrationId}/admit-card`); assert.equal(downloadAdmit.response.status, 200, '规定时间内应可下载准考证'); assert.match(downloadAdmit.data, /测试考生新名/); + assert.match(downloadAdmit.data, /考试考场序号/); + assert.match(downloadAdmit.data, /考场通用名称 \/ 场地代码/); + assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].building)); + assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].floor)); assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/); + const schoolAdmitContext = await schoolAdmin.request('/api/admin/admission-arrangements'); + assert.equal(schoolAdmitContext.response.status, 200, '校级管理员应有校内准考证入口'); + assert.equal(schoolAdmitContext.data.canArrange, false, '校级管理员不能执行整场编排'); + assert.ok(schoolAdmitContext.data.registrations.every(item => item.candidate.schoolId === 'school_hz1'), '校级准考证台账必须限制为本校考生'); + const classAdmitContext = await classAdmin.request('/api/admin/admission-arrangements'); + assert.equal(classAdmitContext.response.status, 200, '班级管理员应有本班准考证入口'); + assert.ok(classAdmitContext.data.registrations.every(item => item.candidate.classId === 'class_hz1_302'), '班级准考证台账必须限制为本班考生'); + + const schoolBatchCards = await schoolAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); + assert.equal(schoolBatchCards.response.status, 200, '校级管理员应可批量下载校内准考证'); + assert.match(schoolBatchCards.response.headers.get('content-disposition') || '', /attachment/); + assert.match(schoolBatchCards.data, /考试考场序号/); + const classBatchCards = await classAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); + assert.equal(classBatchCards.response.status, 200, '班级管理员应可批量下载本班准考证'); + assert.match(classBatchCards.data, /测试考生新名/); + + const classInfoExport = await classAdmin.request(`/api/admin/admission-exports/info?examId=${exam.id}`); + assert.equal(classInfoExport.response.status, 200, '班级管理员应可导出本班准考证信息台账'); + const classInfoBook = new ExcelJS.Workbook(); + await classInfoBook.xlsx.load(classInfoExport.data); + const classInfoSheet = classInfoBook.getWorksheet('准考证信息'); + assert.ok(classInfoSheet, '准考证信息导出应有独立工作表'); + assert.equal(classInfoSheet.rowCount, generatedCard.assignments.length + 2, '准考证信息应按考生逐科导出'); + assert.equal(classInfoSheet.getCell('D3').value, '测试考生新名'); + assert.equal(classInfoSheet.getCell('O3').value, generatedCard.assignments[0].examRoomCode, '信息台账应明确导出考试考场序号'); + assert.equal(classInfoSheet.getCell('P3').value, generatedCard.assignments[0].roomName, '信息台账应另列考场通用名称'); + + const schoolMaterials = await schoolAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`); + assert.equal(schoolMaterials.response.status, 200, '校级管理员应可导出本校维护考点的现场物料'); + const materialsBook = new ExcelJS.Workbook(); + await materialsBook.xlsx.load(schoolMaterials.data); + assert.deepEqual(materialsBook.worksheets.map(sheet => sheet.name), ['桌贴', '门贴', '考场签名单'], '考点物料应包含桌贴、门贴和考场签名单三张表'); + const deskStickerSheet = materialsBook.getWorksheet('桌贴'); + assert.ok(deskStickerSheet.rowCount >= 8, '每张方形桌贴应占用完整标签卡片区域'); + assert.equal(deskStickerSheet.pageSetup.orientation, 'portrait', '方形桌贴应使用 A4 纵向打印'); + assert.equal(deskStickerSheet.getRow(1).height, 32.5, '桌贴应使用固定行高形成方形标签'); + assert.equal(deskStickerSheet.getColumn(1).width, deskStickerSheet.getColumn(3).width, '桌贴卡片各列应等宽'); + assert.ok(deskStickerSheet.model.merges.includes('A1:C1'), '第一张桌贴应跨三列形成完整方形卡片'); + assert.match(String(deskStickerSheet.getCell('A3').value), /考试考场序号/); + assert.equal(deskStickerSheet.getCell('A4').text, generatedCard.assignments[0].examRoomCode, '桌贴应突出考试考场序号'); + assert.match(String(deskStickerSheet.getCell('A5').value), new RegExp(generatedCard.assignments[0].seat), '桌贴应突出座位号'); + assert.match(String(materialsBook.getWorksheet('门贴').getCell('A1').value), /考场门贴/); + assert.equal((await classAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`)).response.status, 403, '班级管理员不得导出考点级现场物料'); + + const paginationRows = Array.from({ length: 7 }, (_, index) => ({ + examCode: 'EX-QA', examName: '桌贴分页测试', subjectName: '语文', subjectDate: '2026-10-01', subjectTime: '09:00—11:00', + centerCode: 'C01', centerName: '测试考点', examRoomCode: '001', roomName: '第一教室', roomCode: 'R01', building: '教学楼', floor: '1 层', + seat: String(index + 1).padStart(2, '0'), cardNumber: `CARD${index + 1}`, candidateName: `考生${index + 1}`, schoolName: '测试学校', className: '测试班' + })); + const paginationBook = new ExcelJS.Workbook(); + await paginationBook.xlsx.load(await buildCenterMaterialsWorkbook(paginationRows, '分页测试')); + assert.equal(paginationBook.getWorksheet('桌贴').pageSetup.printArea, 'A1:G48', '超过六张桌贴时应扩展为两页固定打印区域'); + assert.equal(paginationBook.getWorksheet('桌贴').getRow(32).height, 32.5, '第二页桌贴仍应保持相同方形标签高度'); + const futureExamResponse = await admin.request('/api/admin/exams', { method: 'POST', body: {