This commit is contained in:
2026-07-20 10:45:35 +08:00 Unverified
parent 7094e9b916
commit 352f471ed0
17 changed files with 342 additions and 60 deletions
+51 -12
View File
@@ -49,6 +49,41 @@ export function createAdminRoutes(context) {
permissionsByLevel
} = context;
const passPolicies = new Set(['fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none']);
function normalizeSubjects(input, examStart) {
const source = Array.isArray(input) ? input : String(input || '').split(/[,]/);
return source.map((item, index) => {
const structured = item && typeof item === 'object';
const name = cleanText(structured ? item.name : item, 50);
if (!name) return null;
const fullScore = Number(structured ? item.fullScore : 150);
return {
id: uid('sub'),
name,
date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
start: cleanText(structured ? item.start : '', 5) || '09:00',
end: cleanText(structured ? item.end : '', 5) || '11:00',
fee: Number(structured ? item.fee : 0),
fullScore,
passScore: Number(structured ? item.passScore : fullScore * .6),
order: index + 1
};
}).filter(Boolean);
}
function validateExamScoring(subjects, passPolicy, passValue) {
if (!subjects.length) return '请至少添加一个考试科目';
if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000';
if (subjects.some(item => !Number.isFinite(item.passScore) || item.passScore < 0 || item.passScore > item.fullScore)) return '单科合格分必须在 0 与该科满分之间';
if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内';
if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0);
if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`;
if (['score_ratio', 'rank_percent'].includes(passPolicy) && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '百分比必须大于 0 且不超过 100';
return '';
}
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
@@ -607,10 +642,12 @@ export function createAdminRoutes(context) {
const body = await readJson(request);
const name = cleanText(body.name, 100);
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,]/);
const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目');
const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
const subjects = normalizeSubjects(body.subjects, body.examStart);
const passPolicy = passPolicies.has(body.passPolicy) ? body.passPolicy : 'score_ratio';
const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60);
const scoringError = validateExamScoring(subjects, passPolicy, passValue);
if (scoringError) return sendError(response, 400, scoringError);
const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), passPolicy, passValue, status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
await database.createExam(exam, log);
return sendJson(response, 201, { ok: true, exam });
@@ -623,19 +660,20 @@ export function createAdminRoutes(context) {
if (!exam) return sendError(response, 404, '考试不存在');
const originalStatus = exam.status;
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null;
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null || body.passPolicy != null || body.passValue != null;
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
if (body.passPolicy != null && passPolicies.has(body.passPolicy)) exam.passPolicy = body.passPolicy;
if (body.passValue != null) exam.passValue = Number(body.passValue);
let replaceSubjects = false;
if (body.subjects != null) {
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,]/);
const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean);
if (!names.length) return sendError(response, 400, '请至少添加一个考试科目');
exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
exam.subjects = normalizeSubjects(body.subjects, exam.examStart);
replaceSubjects = true;
}
const scoringError = validateExamScoring(exam.subjects, exam.passPolicy, Number(exam.passValue));
if (scoringError) return sendError(response, 400, scoringError);
if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布');
const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
@@ -693,16 +731,17 @@ export function createAdminRoutes(context) {
const exam = db.exams.find(item => item.id === registration.examId);
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
const score = Number(body.score);
if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间');
const subject = exam.subjects.find(item => item.id === body.subjectId);
if (!Number.isFinite(score) || score < 0 || score > subject.fullScore) return sendError(response, 400, `成绩必须在 0—${subject.fullScore} 之间`);
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId);
const isNew = !result;
if (!result) {
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
db.results.push(result);
}
Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
const ratio = score / subject.fullScore;
Object.assign(result, { score, grade: cleanText(body.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const subject = exam.subjects.find(item => item.id === body.subjectId);
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
await database.saveResult(result, isNew, log);
return sendJson(response, 200, { ok: true, result });
+4 -2
View File
@@ -31,6 +31,7 @@ export function createCandidateRoutes(context) {
maskId,
publicExam,
examRegistrationView,
examResultSummary,
logAction,
excelResourceNames,
excelRowsForResource,
@@ -121,9 +122,10 @@ export function createCandidateRoutes(context) {
const registration = registrations.find(reg => reg.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam.subjects.find(item => item.id === result.subjectId);
return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId };
return { ...result, examId: exam.id, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passScore: subject?.passScore || 90 };
});
return sendJson(response, 200, { ok: true, results });
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
return sendJson(response, 200, { ok: true, results, summaries });
}
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'GET' && admitMatch) {