From 1b6e860013f55f927f278a098f4cdb91829cff48 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 13:25:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database.mjs | 8 ++++++-- scripts/import-test-data.mjs | 2 +- src/client/admin-views.mjs | 10 +++++++++- src/routes/admin.routes.mjs | 6 +++++- tests/system.test.mjs | 17 ++++++++++++++--- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/database.mjs b/database.mjs index 99119db..a543650 100644 --- a/database.mjs +++ b/database.mjs @@ -357,6 +357,10 @@ export function buildSeedOperations(state) { } function stateFromRows(rows) { + const parseJson = (value, fallback) => { + if (value != null && typeof value === 'object') return value; + try { return JSON.parse(value || JSON.stringify(fallback)); } catch { return fallback; } + }; const subjectsByExam = new Map(); for (const row of rows.subjects) { const fullScore = Number(row.full_score ?? 150); @@ -518,7 +522,7 @@ function stateFromRows(rows) { postalCode: row.postal_code || '', guardianName: row.guardian_name || '', guardianPhone: row.guardian_phone || '', - specialtyTypes: (() => { try { return JSON.parse(row.specialty_types || '[]'); } catch { return []; } })(), + specialtyTypes: parseJson(row.specialty_types, []), specialtyCertificate: row.specialty_certificate || '', policyEligibility: row.policy_eligibility || '', profileCompleted: Boolean(row.profile_completed), @@ -759,7 +763,7 @@ function stateFromRows(rows) { userId: row.user_id || null, schoolId: row.school_id || null, status: row.status, - payload: (() => { try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; } })(), + payload: parseJson(row.payload_json, {}), createdAt: row.created_at, updatedAt: row.updated_at })), diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs index 6382de1..688db9a 100644 --- a/scripts/import-test-data.mjs +++ b/scripts/import-test-data.mjs @@ -73,7 +73,7 @@ const mysqlBusinessTables = [ 'schools', 'school_classes', 'candidate_profiles', 'notices', 'exams', 'exam_subjects', 'registrations', 'registration_subjects', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects', 'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms', - 'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'audit_logs' + 'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'admission_records', 'audit_logs' ]; async function prepareMysql() { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 28bf02d..3bbcf21 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -61,8 +61,15 @@ export function createAdminViews(context) { 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissions(data), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); + if (page === 'admissions') mountPreferenceLedger(data); } catch (error) { renderError(error); } } + + function mountPreferenceLedger(data) { + const container = app.querySelector('.portal-content'); + if (!container || !data.preferences?.length) return; + container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${data.preferences.map(item => ``).join('')}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
`); + } function adminDashboard(data) { const m = data.metrics; @@ -139,7 +146,8 @@ export function createAdminViews(context) { const selected = data.settings.find(item => item.status !== 'completed') || data.settings[0]; const pendingPlans = data.plans.filter(item => item.status === 'pending'); const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); - const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }; + const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止' }; + if (selected && !phaseLabels[selected.status]) phaseLabels[selected.status] = { matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }[selected.status] || selected.status; return `
ADMISSION COMMAND

中考招生录取控制台

志愿内容仅超级管理员可见且不可代改;投档和录取状态变更均进入审计日志。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}

考试志愿设置

不是所有考试都需要开启。

${selected ? `
` : ''}

代上传招生计划

每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。指标分配可由招生学校提交后审核。

招生计划审核

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成提交人状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${category.specialtyType ? ` · ${h(category.specialtyType)}` : ''}`).join('
')}
${h(plan.payload.submittedBy || '超级管理员')}${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划

投档与退档监督

超级管理员可见,任何管理员均不能修改考生志愿
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.payload.totalScore)} 分 · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; } diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index a40e26f..84cdfce 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -175,9 +175,11 @@ export function createAdminRoutes(context) { 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 }; + const requestedStatus = admissionPhases.has(body.status) ? body.status : 'draft'; + const manualPhases = ['draft', 'filling', 'closed']; + const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft'); 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) || '等待志愿填报开始' }; @@ -195,6 +197,7 @@ export function createAdminRoutes(context) { 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); + if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划'); 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: '超级管理员代上传并审核通过' } }); @@ -239,6 +242,7 @@ export function createAdminRoutes(context) { } const body = await readJson(request); const now = nowIso(); + if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录'); 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 }); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 2c7d944..6a1a4f9 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { pbkdf2Sync, randomBytes } from 'node:crypto'; import { readFile, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { createServer as createNetServer } from 'node:net'; import assert from 'node:assert/strict'; import ExcelJS from 'exceljs'; import { createDatabase, relationalTables } from '../database.mjs'; @@ -13,7 +14,14 @@ import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs'; const root = resolve(process.cwd()); assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果'); -const port = 4182; +const port = await new Promise((resolvePort, rejectPort) => { + const probe = createNetServer(); + probe.once('error', rejectPort); + probe.listen(0, '127.0.0.1', () => { + const selectedPort = probe.address().port; + probe.close(error => error ? rejectPort(error) : resolvePort(selectedPort)); + }); +}); const baseUrl = `http://127.0.0.1:${port}`; const testDb = resolve(root, 'data', 'test-db.sqlite'); const mysqlRuleSchemas = mysqlSchema.filter(statement => /CREATE TABLE IF NOT EXISTS (?:admission_number_rules|number_rules)\b/.test(statement)); @@ -961,8 +969,11 @@ try { console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); } finally { - server.kill('SIGTERM'); - await new Promise(resolveWait => server.once('exit', resolveWait)); + if (server.exitCode == null && server.signalCode == null) { + const serverExit = new Promise(resolveWait => server.once('exit', resolveWait)); + server.kill('SIGTERM'); + await serverExit; + } await rm(testDb, { force: true }); await rm(`${testDb}-shm`, { force: true }); await rm(`${testDb}-wal`, { force: true });