Add structured admission plans and school role management

This commit is contained in:
2026-07-21 14:04:19 +08:00 Unverified
parent 1b6e860013
commit 5e0bee60e2
23 changed files with 483 additions and 82 deletions
+38 -11
View File
@@ -1,6 +1,7 @@
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';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
export function createAdminRoutes(context) {
const {
@@ -112,7 +113,7 @@ export function createAdminRoutes(context) {
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),
quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyCategory: cleanText(item.specialtyCategory, 30), 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)
@@ -147,7 +148,8 @@ export function createAdminRoutes(context) {
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 qualification = resolveProfileSpecialty(profile);
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, 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) || {};
@@ -155,12 +157,12 @@ export function createAdminRoutes(context) {
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) });
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), 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 school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
const username = cleanText(body.username, 80);
const password = String(body.password || '');
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
@@ -191,11 +193,14 @@ export function createAdminRoutes(context) {
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 school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
const categories = normalizeAdmissionCategories(body.categories);
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.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');
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
const now = nowIso();
@@ -305,11 +310,14 @@ export function createAdminRoutes(context) {
const name = cleanText(body.name, 100);
const code = cleanText(body.code, 40).toUpperCase();
const address = cleanText(body.address, 200);
const isSourceSchool = body.isSourceSchool !== false;
const isAdmissionSchool = body.isAdmissionSchool !== false;
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
const school = { id: uid('school'), name, code, address, active: body.active !== false };
const school = { id: uid('school'), name, code, address, isSourceSchool, isAdmissionSchool, active: body.active !== false };
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
return sendJson(response, 201, { ok: true, school });
}
@@ -322,11 +330,14 @@ export function createAdminRoutes(context) {
const name = cleanText(body.name ?? school.name, 100);
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
const address = cleanText(body.address ?? school.address, 200);
const isSourceSchool = body.isSourceSchool == null ? school.isSourceSchool : Boolean(body.isSourceSchool);
const isAdmissionSchool = body.isAdmissionSchool == null ? school.isAdmissionSchool : Boolean(body.isAdmissionSchool);
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
Object.assign(school, { name, code, address, active: body.active == null ? school.active : Boolean(body.active) });
Object.assign(school, { name, code, address, isSourceSchool, isAdmissionSchool, active: body.active == null ? school.active : Boolean(body.active) });
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
return sendJson(response, 200, { ok: true, school });
}
@@ -343,6 +354,7 @@ export function createAdminRoutes(context) {
}
if (pathname === '/api/admin/classes' && request.method === 'POST') {
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
if (!db.schools.some(item => item.id === user.schoolId && item.active && item.isSourceSchool)) return sendError(response, 409, '当前学校未设置为已启用的生源校');
const body = await readJson(request);
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
@@ -374,7 +386,7 @@ export function createAdminRoutes(context) {
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
}));
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
return sendJson(response, 200, { ok: true, admins, schools: db.schools.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
}
if (pathname === '/api/admin/admins' && request.method === 'POST') {
const body = await readJson(request);
@@ -387,7 +399,7 @@ export function createAdminRoutes(context) {
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
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 !== 'super' && !db.schools.some(item => item.id === schoolId && item.active && item.isSourceSchool)) return sendError(response, 400, '校级和班级管理员必须绑定已启用的生源校');
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
@@ -425,7 +437,7 @@ export function createAdminRoutes(context) {
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.map(item => candidateAccountBatchView(db, item));
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) });
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active && item.isSourceSchool) });
}
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
@@ -1293,6 +1305,21 @@ export function createAdminRoutes(context) {
const result = await commitResultImport(db, user, body.rows);
return sendJson(response, 200, { ok: true, ...result });
}
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
if (request.method === 'PATCH' && featureScoreMatch) {
if (!requirePermission(user, response, '*')) return true;
const registration = db.registrations.find(item => item.id === featureScoreMatch[1] && item.status === 'approved');
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
const exam = db.exams.find(item => item.id === registration.examId);
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,特征分已永久锁定');
const body = await readJson(request);
const featureScore = Number(body.featureScore);
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, '特征分必须在 0—1000 之间');
registration.featureScore = Number(featureScore.toFixed(2));
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
await database.updateFeatureScore(registration, logAction(db, user, '登记特征分', `${profile?.name || registration.userId} · ${exam?.name || registration.examId} · ${registration.featureScore}`));
return sendJson(response, 200, { ok: true, registration });
}
if (request.method === 'POST' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);