Implement application updates and refinements
This commit is contained in:
+283
-17
@@ -3,6 +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';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const port = Number(process.env.PORT || 4173);
|
||||
@@ -45,7 +46,7 @@ function seedDatabase() {
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
return {
|
||||
meta: { version: 5, createdAt: nowIso() },
|
||||
meta: { version: 6, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: {
|
||||
name: '海州市教育考试中心',
|
||||
@@ -125,11 +126,11 @@ function seedDatabase() {
|
||||
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||
],
|
||||
testRooms: [
|
||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||
],
|
||||
centerChangeRequests: [],
|
||||
centerChangeRooms: [],
|
||||
@@ -195,6 +196,28 @@ async function readJson(request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 });
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 });
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function sendWorkbook(response, buffer, filename) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
'Content-Length': buffer.length,
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
response.end(buffer);
|
||||
}
|
||||
|
||||
function parseCookies(request) {
|
||||
return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
|
||||
const index = part.indexOf('=');
|
||||
@@ -429,17 +452,14 @@ function parseCenterChange(db, body, schoolId, center = null) {
|
||||
const roomName = cleanText(room.name, 80);
|
||||
const building = cleanText(room.building, 80);
|
||||
const capacity = Number(room.capacity);
|
||||
const seatStart = Number(room.seatStart);
|
||||
const seatEnd = Number(room.seatEnd);
|
||||
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) {
|
||||
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 });
|
||||
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) {
|
||||
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 });
|
||||
}
|
||||
if (seatEnd - seatStart + 1 > capacity) 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, seatStart, seatEnd,
|
||||
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)
|
||||
};
|
||||
@@ -485,6 +505,180 @@ function logAction(db, user, action, detail) {
|
||||
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, 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,
|
||||
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);
|
||||
return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: exam?.subjects.find(item => item.id === result.subjectId)?.name || '', 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, '姓名、性别、证件号码和手机号必填');
|
||||
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), 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, 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 > 150) throw excelImportError(row, '报名号、考试、科目或成绩无效');
|
||||
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 };
|
||||
Object.assign(result, { score, grade: cleanText(row.grade, 10) || (score >= 120 ? 'A' : score >= 90 ? 'B' : score >= 60 ? '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]));
|
||||
}
|
||||
@@ -688,9 +882,66 @@ async function handleAdmin(request, response, pathname) {
|
||||
});
|
||||
}
|
||||
|
||||
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/);
|
||||
if (excelMatch && request.method === 'GET') {
|
||||
const resource = excelMatch[1];
|
||||
if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在');
|
||||
if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据');
|
||||
if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场');
|
||||
if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料');
|
||||
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||
const template = requestUrl.searchParams.get('template') === '1';
|
||||
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
||||
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
if (excelMatch && request.method === 'POST') {
|
||||
const resource = excelMatch[1];
|
||||
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
|
||||
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
|
||||
const result = await importExcelResource(db, user, resource, rows);
|
||||
return sendJson(response, 200, { ok: true, ...result });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/school-organization' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
|
||||
const school = db.schools.find(item => item.id === user.schoolId);
|
||||
const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({
|
||||
...item,
|
||||
candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length,
|
||||
admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active }))
|
||||
}));
|
||||
return sendJson(response, 200, { ok: true, school, classes });
|
||||
}
|
||||
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
||||
const body = await readJson(request);
|
||||
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false };
|
||||
await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`));
|
||||
return sendJson(response, 201, { ok: true, schoolClass });
|
||||
}
|
||||
const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/);
|
||||
if (classMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级');
|
||||
const body = await readJson(request);
|
||||
const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 404, '班级不存在');
|
||||
const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) });
|
||||
await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`));
|
||||
return sendJson(response, 200, { ok: true, schoolClass });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admins' && request.method === 'GET') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const admins = db.users.filter(item => item.role === 'admin').map(item => ({
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员');
|
||||
const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).map(item => ({
|
||||
...safeUser(item),
|
||||
active: item.active,
|
||||
levelName: adminLevelNames[item.adminLevel],
|
||||
@@ -700,15 +951,15 @@ async function handleAdmin(request, response, pathname) {
|
||||
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||
}
|
||||
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const username = cleanText(body.username, 50);
|
||||
const password = String(body.password || '');
|
||||
const displayName = cleanText(body.displayName, 50);
|
||||
const adminLevel = cleanText(body.adminLevel, 20);
|
||||
const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20);
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员');
|
||||
if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码');
|
||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
||||
const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64);
|
||||
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
|
||||
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
||||
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
|
||||
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
||||
@@ -717,6 +968,21 @@ async function handleAdmin(request, response, pathname) {
|
||||
await database.createAdmin(created, log);
|
||||
return sendJson(response, 201, { ok: true, admin: safeUser(created) });
|
||||
}
|
||||
const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/);
|
||||
if (adminMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员');
|
||||
const body = await readJson(request);
|
||||
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId);
|
||||
if (!target) return sendError(response, 404, '班级管理员不存在');
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 400, '请选择本校有效班级');
|
||||
const password = String(body.password || '');
|
||||
if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位');
|
||||
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) });
|
||||
if (password) target.passwordHash = hashPassword(password);
|
||||
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`));
|
||||
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
|
||||
}
|
||||
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
|
||||
Reference in New Issue
Block a user