${escapeHtml(exam.name)}
| 科目 | 日期 | 时间 | 考场 | 座位 |
|---|
import { createServer } from 'node:http'; 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 { createAdminRoutes } from './src/routes/admin.routes.mjs'; import { createCandidateRoutes } from './src/routes/candidate.routes.mjs'; import { createAuthRoutes } from './src/routes/auth.routes.mjs'; import { createPublicRoutes } from './src/routes/public.routes.mjs'; import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs'; import { createSessionManager } from './src/security/session.mjs'; import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs'; import { createSeedDatabase } from './src/data/seed.mjs'; import { resolveRegion } from './src/data/region-service.mjs'; const root = resolve(process.cwd()); const port = Number(process.env.PORT || 4173); const host = process.env.HOST || '127.0.0.1'; const sessions = new Map(); const staticFiles = new Set([ '/index.html', '/styles.css', '/app.js', '/src/client/api.mjs', '/src/client/admin-views.mjs', '/src/client/candidate-views.mjs', '/src/client/public-views.mjs', '/src/client/state.mjs', '/src/client/ui.mjs', '/src/client/region-select.mjs', '/src/data/china-regions.mjs' ]); const mimeTypes = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' }; function nowIso() { return new Date().toISOString(); } function uid(prefix) { return `${prefix}_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`; } function hashPassword(password, salt = randomBytes(16).toString('hex')) { const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex'); return `${salt}:${hash}`; } function verifyPassword(password, stored) { const [salt, expected] = String(stored).split(':'); if (!salt || !expected) return false; const actual = pbkdf2Sync(password, salt, 120000, 32, 'sha256'); const expectedBuffer = Buffer.from(expected, 'hex'); return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer); } const seedDatabase = () => createSeedDatabase({ nowIso, hashPassword }); const database = await createDatabase({ root, seed: seedDatabase }); const readDb = () => database.read(); const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError }); const requirePermission = createPermissionGuard(sendError); function adminsForStep(db, adminLevel, profile) { return db.users.filter(item => { if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false; if (adminLevel === 'super') return true; if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId); if (adminLevel === 'class') return Boolean( profile?.schoolId && profile?.classId && item.schoolId === profile.schoolId && item.classId === profile.classId ); return false; }); } function selectAdminForStep(db, adminLevel, profile) { const pendingByAdmin = new Map(); for (const instance of db.workflowInstances) { if (instance.status !== 'pending' || !instance.assigneeId) continue; pendingByAdmin.set(instance.assigneeId, (pendingByAdmin.get(instance.assigneeId) || 0) + 1); } const assignedByAdmin = new Map(); for (const action of db.workflowActions) { if (!action.toAssigneeId) continue; assignedByAdmin.set(action.toAssigneeId, (assignedByAdmin.get(action.toAssigneeId) || 0) + 1); } return adminsForStep(db, adminLevel, profile).sort((left, right) => (pendingByAdmin.get(left.id) || 0) - (pendingByAdmin.get(right.id) || 0) || (assignedByAdmin.get(left.id) || 0) - (assignedByAdmin.get(right.id) || 0) || String(left.createdAt || '').localeCompare(String(right.createdAt || '')) || left.id.localeCompare(right.id) )[0] || null; } function activeWorkflow(db, businessType) { return db.workflows.find(item => item.businessType === businessType && item.active); } function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) { const workflow = activeWorkflow(db, businessType); if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 }); const firstStep = workflow.steps[0]; const assignee = selectAdminForStep(db, firstStep.adminLevel, profile); if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 }); const instance = { id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1, assigneeId: assignee.id, createdAt: nowIso(), completedAt: null }; const action = { id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批', fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso() }; return { workflow, instance, action }; } function workflowView(db, instance) { if (!instance) return null; const workflow = db.workflows.find(item => item.id === instance.workflowId); const assignee = db.users.find(item => item.id === instance.assigneeId); const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({ ...item, actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统', fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '', toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || '' })); return { ...instance, workflowName: workflow?.name || '未命名流程', steps: workflow?.steps || [], currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null, assignee: assignee ? safeUser(assignee) : null, actions }; } function pendingWorkflow(db, businessType, businessId) { return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending'); } function candidateSequence(db, rule, schoolId, year) { const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean); const prefix = prefixParts.join(rule.separator); return db.users.filter(item => item.role === 'candidate' && item.candidateNumber && (!prefix || item.candidateNumber.startsWith(prefix))).length + 1; } function generateCandidateNumber(db, profile, year = String(new Date().getFullYear())) { const rule = db.numberRules.find(item => item.active); if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 }); const school = db.schools.find(item => item.id === profile.schoolId); const sequence = candidateSequence(db, rule, profile.schoolId, year); const parts = rule.segments.map(segment => { if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4)); if (segment.type === 'school_code') return school?.code || 'NOSCHOOL'; if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X'; if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0'); return cleanText(segment.value, 20).toUpperCase(); }); return { number: parts.join(rule.separator), ruleId: rule.id }; } function cleanText(value, max = 200) { return String(value ?? '').trim().slice(0, max); } function centerScopeProfile(db, schoolId) { const school = db.schools.find(item => item.id === schoolId); return { schoolId, classId: null, school: school?.name || '', grade: '' }; } function workflowScopeProfile(db, instance) { if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null; if (instance.businessType === 'registration_review') { const registration = db.registrations.find(item => item.id === instance.businessId); return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; } if (instance.businessType === 'center_change') { const change = db.centerChangeRequests.find(item => item.id === instance.businessId); return change ? centerScopeProfile(db, change.schoolId) : null; } if (instance.businessType === 'candidate_account_batch') { const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId); return batch ? centerScopeProfile(db, batch.schoolId) : null; } if (instance.businessType === 'score_appeal') { const result = db.results.find(item => item.id === instance.businessId); const registration = db.registrations.find(item => item.id === result?.registrationId); return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; } return null; } function candidateAccountBatchView(db, batch) { const items = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position); const quotaMap = new Map(); for (const item of items) quotaMap.set(item.classId, (quotaMap.get(item.classId) || 0) + 1); const instance = db.workflowInstances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batch.id); return { ...batch, schoolName: db.schools.find(item => item.id === batch.schoolId)?.name || '', requesterName: db.users.find(item => item.id === batch.requestedBy)?.displayName || '原提交人', totalCount: items.length, quotas: [...quotaMap.entries()].map(([classId, count]) => { const schoolClass = db.classes.find(item => item.id === classId); return { classId, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '', count }; }), items: items.map(item => { const schoolClass = db.classes.find(entry => entry.id === item.classId); return { ...item, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '' }; }), workflow: workflowView(db, instance) }; } function centerChangeView(db, change) { const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id); return { ...change, schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '', rooms: db.centerChangeRooms.filter(item => item.requestId === change.id), workflow: workflowView(db, instance) }; } function parseCenterChange(db, body, schoolId, center = null) { const code = cleanText(body.code, 30).toUpperCase(); const name = cleanText(body.name, 100); const address = cleanText(body.address, 200); const region = resolveRegion(body); const rooms = Array.isArray(body.rooms) ? body.rooms : []; if (!code || !name || !address || !region) throw Object.assign(new Error('请填写考点代码、名称、省市区县和详细地址'), { status: 400 }); if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 }); const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id) || db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id); if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 }); const roomCodes = new Set(); const normalizedRooms = rooms.map((room, index) => { const roomCode = cleanText(room.code, 30).toUpperCase(); const roomName = cleanText(room.name, 80); const building = cleanText(room.building, 80); const capacity = Number(room.capacity); if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) { throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 }); } if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 }); roomCodes.add(roomCode); return { id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName, building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity, roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard', status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300) }; }); return { center: { schoolId, code, name, ...region, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50), managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30), gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500), centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000) }, rooms: normalizedRooms }; } function maskId(value) { const text = String(value || ''); return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text; } function publicExam(exam) { const now = Date.now(); const start = new Date(exam.registrationStart).getTime(); const end = new Date(exam.registrationEnd).getTime(); return { ...exam, totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0), registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open' }; } function examResultSummary(db, registration) { const exam = db.exams.find(item => item.id === registration.examId); if (!exam) return null; const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); const published = db.results.filter(result => result.registrationId === registration.id && result.published); const resultsBySubject = new Map(published.map(result => [result.subjectId, result])); const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id)); const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0); const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0); const scoreRatio = fullScore ? total / fullScore * 100 : 0; const policy = exam.passPolicy || 'score_ratio'; const value = Number(exam.passValue ?? 60); let qualified = null; let rank = null; let cohortSize = null; if (complete && policy === 'fixed_score') qualified = total >= value; if (complete && policy === 'score_ratio') qualified = scoreRatio >= value; if (complete && policy === 'subject_scores') qualified = subjects.every(subject => Number(resultsBySubject.get(subject.id).score) >= Number(subject.passScore)); if (complete && policy === 'none') qualified = null; if (complete && policy === 'rank_percent') { const subjectKey = [...registration.subjectIds].sort().join('|'); const totals = db.registrations .filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey) .map(item => { const itemResults = db.results.filter(result => result.registrationId === item.id && result.published); if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null; return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0); }) .filter(item => item != null); cohortSize = totals.length; rank = 1 + totals.filter(item => item > total).length; qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100)); } return { examId: exam.id, complete, publishedSubjects: published.length, subjectCount: subjects.length, total, fullScore, scoreRatio: Number(scoreRatio.toFixed(2)), passPolicy: policy, passValue: value, qualified, rank, cohortSize }; } function examRegistrationView(db, registration) { const exam = db.exams.find(item => item.id === registration.examId); const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)); const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending') || db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0]; return { ...registration, exam, subjects, workflow: workflowView(db, instance) }; } function logAction(db, user, action, detail) { const log = { id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() }; db.auditLogs.unshift(log); db.auditLogs = db.auditLogs.slice(0, 200); return log; } const excelResourceNames = { classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账' }; 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 => ({ schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用' })); if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({ schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName, username: item.username, initialPassword: '', status: item.active ? '启用' : '停用' })); if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 })); if (resource === 'account_results') { const batchId = cleanText(searchParams.get('batchId'), 64); const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId)); if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 }); return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({ batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword })); } if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({ candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender, idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email, nativePlace: profile.nativePlace, provinceCode: profile.provinceCode, provinceName: profile.provinceName, cityCode: profile.cityCode, cityName: profile.cityName, districtCode: profile.districtCode, districtName: profile.districtName, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade, ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone })); if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => { const rooms = db.testRooms.filter(room => room.centerId === center.id); return (rooms.length ? rooms : [{}]).map(room => ({ schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name, provinceCode: center.provinceCode, provinceName: center.provinceName, cityCode: center.cityCode, cityName: center.cityName, districtCode: center.districtCode, districtName: center.districtName, address: center.address, managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact, emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport, centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes, roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '', seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '', roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || '' })); }); if (resource === 'results') { const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { const registration = db.registrations.find(item => item.id === result.registrationId); const exam = db.exams.find(item => item.id === registration?.examId); const subject = exam?.subjects.find(item => item.id === result.subjectId); 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 ? '发布' : '不发布' }; }); } return []; } function excelImportError(row, message) { return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 }); } async function importExcelResource(db, user, resource, rows) { if (resource === 'classes') { if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 }); for (const row of rows) { const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内'); const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60); if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空'); const existing = db.classes.find(item => item.schoolId === school.id && item.name === name); const schoolClass = existing || { id: uid('class'), schoolId: school.id }; Object.assign(schoolClass, { name, grade, active: row.status !== '停用' }); await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`)); if (!existing) db.classes.push(schoolClass); } return { count: rows.length }; } if (resource === 'class_admins') { if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 }); for (const row of rows) { const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100)); if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效'); const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || ''); if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空'); const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase()); if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用'); if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位'); if (existing) { Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' }); if (password) existing.passwordHash = hashPassword(password); await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`)); } else { const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() }; await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`)); db.users.push(created); } } return { count: rows.length }; } if (resource === 'account_quotas') { if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 }); const quotas = rows.filter(row => Number(row.count) > 0).map(row => { const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active); if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间'); return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) }; }); if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 }); return { count: quotas.length, quotas }; } if (resource === 'candidates') { if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 }); for (const row of rows) { const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); const profile = db.candidateProfiles.find(item => item.userId === account?.id); if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内'); if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程'); const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100)); if (!schoolClass) throw excelImportError(row, '班级名称无效'); const required = ['name', 'gender', 'idNumber', 'phone']; if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填'); const region = resolveRegion(row); if (!region) throw excelImportError(row, '省、市或区县代码无效,或上下级不匹配'); Object.assign(profile, { name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30), email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), ...region, address: cleanText(row.address, 200), classId: schoolClass.id, grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20), guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso() }); const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); await database.updateCandidateProfile(profile, profile.name, instance, action); } return { count: rows.length }; } if (resource === 'centers') { if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 }); const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase()); for (const [centerCode, centerRows] of groups) { const first = centerRows[0]; const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId)); if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效'); const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode); if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校'); if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更'); const body = { schoolId: school.id, code: centerCode, name: first.centerName, provinceCode: first.provinceCode, cityCode: first.cityCode, districtCode: first.districtCode, address: first.address, managerName: first.managerName, managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime, transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes, rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan, roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType, status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes })) }; const parsed = parseCenterChange(db, body, school.id, existing || null); const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id); await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`)); } return { count: groups.size }; } if (resource === 'results') { if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 }); for (const row of rows) { const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60)); const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50)); const score = Number(row.score); if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > subject.fullScore) throw excelImportError(row, `报名号、考试、科目无效,或成绩不在 0—${subject?.fullScore || 0} 之间`); let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id); const isNew = !result; if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id }; const ratio = score / subject.fullScore; Object.assign(result, { score, grade: cleanText(row.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null }); await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`)); if (isNew) db.results.push(result); } return { count: rows.length }; } throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 }); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function admitCardHtml(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 rows = subjects.map(subject => { const assignment = assignments.get(subject.id) || {}; return `
${escapeHtml(exam.name)}
| 科目 | 日期 | 时间 | 考场 | 座位 |
|---|