This commit is contained in:
2026-07-21 12:49:59 +08:00 Unverified
parent 97bbe07789
commit ae528b20e9
24 changed files with 770 additions and 30 deletions
+127
View File
@@ -1,5 +1,6 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
export function createAdminRoutes(context) {
const {
@@ -108,6 +109,16 @@ export function createAdminRoutes(context) {
return '';
}
function normalizeAdmissionCategories(input) {
return (Array.isArray(input) ? input : []).map((item, index) => ({
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyType: cleanText(item.specialtyType, 80),
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
})).filter(item => item.sourceSchoolId && item.quota > 0)
})).filter(item => item.code && item.name && item.quota > 0);
}
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
@@ -128,6 +139,122 @@ export function createAdminRoutes(context) {
classes: db.classes
});
}
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) }));
const placements = admissionRecords(db, 'placement').map(placement => {
const account = db.users.find(item => item.id === placement.userId) || {};
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [] }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' };
});
const preferences = admissionRecords(db, 'preference').map(preference => {
const account = db.users.find(item => item.id === preference.userId) || {};
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) };
});
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser);
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), exams: db.exams.filter(item => !item.archivedAt) });
}
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
const body = await readJson(request);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
const username = cleanText(body.username, 80);
const password = String(body.password || '');
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '登录账号已存在');
const account = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admission_school', schoolId: school.id, displayName: cleanText(body.displayName, 80) || `${school.name}招生办`, active: true, createdAt: nowIso() };
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
return sendJson(response, 201, { ok: true, account: safeUser(account) });
}
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
if (settingMatch && request.method === 'PUT') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt);
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
const body = await readJson(request);
const status = admissionPhases.has(body.status) ? body.status : 'draft';
const now = nowIso();
const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now };
setting.status = status;
setting.updatedAt = now;
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
return sendJson(response, 200, { ok: true, setting });
}
if (pathname === '/api/admin/admission-plans' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
const categories = normalizeAdmissionCategories(body.categories);
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数');
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID');
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
const now = nowIso();
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
}
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
if (planReviewMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核招生计划');
const plan = admissionRecords(db, 'plan').find(item => item.id === planReviewMatch[1]);
if (!plan) return sendError(response, 404, '招生计划不存在');
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
plan.status = body.status;
plan.updatedAt = nowIso();
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
return sendJson(response, 200, { ok: true, plan });
}
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
if (actionMatch && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以执行投档与录取操作');
const setting = admissionSetting(db, actionMatch[1]);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开启志愿填报');
const action = actionMatch[2];
if (action === 'match') {
if (!['closed', 'supplementary'].includes(setting.status)) return sendError(response, 409, '请先结束当前填报阶段再投档');
const placements = buildVolunteerPlacements(db, setting, { uid, nowIso });
setting.status = 'school_review'; setting.updatedAt = nowIso(); setting.payload.progress = `${setting.payload.round || 1} 轮投档完成,${placements.length} 人已发送招生学校审核`;
await database.saveAdmissionRecords([setting, ...placements], logAction(db, user, '执行分数优先志愿投档', `${setting.examId} · ${placements.length}`));
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
}
if (action === 'finalize') {
const placements = admissionRecords(db, 'placement', setting.examId);
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
const now = nowIso();
const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now }));
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果已经通知并自动公示'; setting.payload.completedAt = now;
await database.saveAdmissionRecords([setting, ...admitted, ...notifications], logAction(db, user, '结束录取并发布结果', `${setting.examId} · ${admitted.length}`));
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows({ ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }, setting.examId) });
}
const body = await readJson(request);
const now = nowIso();
setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' };
await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round}`));
return sendJson(response, 200, { ok: true, setting });
}
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
if (withdrawalMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
if (!placement) return sendError(response, 404, '待审核退档申请不存在');
const body = await readJson(request);
placement.status = body.approved === true ? 'withdrawn' : 'admitted';
placement.updatedAt = nowIso();
placement.payload.withdrawalReviewNote = cleanText(body.reviewNote, 500);
await database.saveAdmissionRecord(placement, logAction(db, user, body.approved === true ? '批准退档' : '驳回退档', placement.id));
return sendJson(response, 200, { ok: true, placement });
}
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
if (excelMatch && request.method === 'GET') {