diff --git a/README.md b/README.md
index 621c5ca..991c93f 100644
--- a/README.md
+++ b/README.md
@@ -256,6 +256,21 @@ npm run seed-test-data:mysql -- --force
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。
+## 中考志愿填报与招生录取
+
+系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
+
+1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
+2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
+3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
+4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
+5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
+6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
+
+公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。
+
+数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
+
## 手动测试数据账号
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
diff --git a/app.js b/app.js
index edc0d9b..3c5dadf 100644
--- a/app.js
+++ b/app.js
@@ -1,6 +1,7 @@
import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs';
+import { createAdmissionViews } from './src/client/admission-views.mjs';
import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
@@ -59,6 +60,7 @@ const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, pa
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
+const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
function navigate(route) {
location.hash = route;
@@ -74,6 +76,7 @@ async function renderRoute() {
else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page);
+ else if (section === 'admission_school') await renderAdmission(page);
else navigate('home');
}
@@ -181,6 +184,35 @@ document.addEventListener('click', async event => {
setModal(`
${h(notice.category)}${h(notice.title)}
${formatDate(notice.publishAt,true)} · ${h(notice.author)}
${contentHtml}`); return;
}
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
+ if (action === 'admission-plan-review') {
+ const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
+ if (reviewNote == null) return;
+ await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
+ toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute();
+ }
+ if (action === 'admission-match') {
+ if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
+ const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' });
+ toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute();
+ }
+ if (action === 'admission-finalize') {
+ if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return;
+ const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' });
+ await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute();
+ }
+ if (action === 'admission-supplementary') {
+ const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00)', '');
+ if (!preferenceEnd) return;
+ await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } });
+ toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute();
+ }
+ if (action === 'withdrawal-review') {
+ const approved = target.dataset.approved === 'true';
+ const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', '');
+ if (reviewNote == null) return;
+ await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
+ toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
+ }
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
event.preventDefault();
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
@@ -527,6 +559,12 @@ document.addEventListener('submit', async event => {
} else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
+ } else if (kind === 'volunteer-preference') {
+ const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => {
+ const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode };
+ });
+ await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
+ toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
} else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -548,6 +586,27 @@ document.addEventListener('submit', async event => {
const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
+ } else if (kind === 'admission-setting') {
+ const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5);
+ await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body });
+ toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute();
+ } else if (kind === 'admission-account') {
+ await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
+ form.reset(); toast('招生学校账号已创建'); renderRoute();
+ } else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
+ const body = formObject(form);
+ body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => {
+ const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim());
+ const indicatorAllocations = indicators.split(/[,,]/).map(entry => { const [sourceSchoolId, count] = entry.split(':').map(item => item.trim()); return { sourceSchoolId, quota: Number(count) }; }).filter(item => item.sourceSchoolId && item.quota > 0);
+ return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations };
+ }).filter(item => item.name && item.quota > 0);
+ if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划');
+ await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body });
+ toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
+ } else if (kind === 'placement-review') {
+ const body = formObject(form);
+ await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
+ toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
} else if (kind === 'school-form') {
const body = formObject(form); body.active = form.active.checked;
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
diff --git a/database.mjs b/database.mjs
index f8dcd38..99119db 100644
--- a/database.mjs
+++ b/database.mjs
@@ -35,6 +35,7 @@ export const relationalTables = [
'workflow_steps',
'workflow_instances',
'workflow_actions',
+ 'admission_records',
'audit_logs'
];
@@ -44,7 +45,7 @@ function validateState(state, source = '数据库') {
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
'admissionNumberRules', 'arrangementPlans',
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
- 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
+ 'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs'
];
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
throw new Error(`${source}中的应用数据格式无效`);
@@ -59,7 +60,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
add(
- 'UPDATE schema_metadata SET schema_version = 17, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
+ 'UPDATE schema_metadata SET schema_version = 18, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
@@ -335,6 +336,16 @@ export function buildSeedOperations(state) {
);
}
+ for (const record of state.admissionRecords) {
+ add(
+ `INSERT INTO admission_records (
+ id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.id, record.kind, record.examId, nullable(record.userId), nullable(record.schoolId), record.status,
+ JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
+ );
+ }
+
for (const log of state.auditLogs) {
add(
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
@@ -507,6 +518,9 @@ 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 []; } })(),
+ specialtyCertificate: row.specialty_certificate || '',
+ policyEligibility: row.policy_eligibility || '',
profileCompleted: Boolean(row.profile_completed),
status: row.status,
reviewNote: row.review_note || '',
@@ -738,6 +752,17 @@ function stateFromRows(rows) {
toAssigneeId: row.to_assignee_id,
createdAt: row.created_at
})),
+ admissionRecords: rows.admissionRecords.map(row => ({
+ id: row.id,
+ kind: row.kind,
+ examId: row.exam_id,
+ userId: row.user_id || null,
+ schoolId: row.school_id || null,
+ status: row.status,
+ payload: (() => { try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; } })(),
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ })),
auditLogs: rows.auditLogs.map(row => ({
id: row.id,
actorId: row.actor_id,
@@ -779,6 +804,7 @@ function readSqliteRows(connection) {
workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(),
workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(),
workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(),
+ admissionRecords: connection.prepare('SELECT * FROM admission_records ORDER BY created_at, id').all(),
auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all()
};
}
@@ -815,6 +841,7 @@ async function readMysqlRows(connection) {
workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'),
workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'),
workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'),
+ admissionRecords: await query('SELECT * FROM admission_records ORDER BY created_at, id'),
auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC')
};
}
@@ -925,6 +952,7 @@ function createRepository({ client, location, read, transaction, close }) {
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
+ specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
WHERE id = ?`,
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
@@ -933,7 +961,8 @@ function createRepository({ client, location, read, transaction, close }) {
optional(profile.address), optional(profile.schoolId),
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
- optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
+ optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), JSON.stringify(profile.specialtyTypes || []),
+ optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
optional(profile.reviewerId), profile.updatedAt, profile.id
),
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId)
@@ -1186,6 +1215,43 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log)
]);
},
+ async createAdmissionSchoolAccount(user, log) {
+ await transaction([
+ operation(
+ `INSERT INTO users (
+ id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
+ ) VALUES (?, ?, ?, 'admission_school', NULL, ?, NULL, ?, ?, ?)`,
+ user.id, user.username, user.passwordHash, user.schoolId, user.active === false ? 0 : 1, user.displayName, user.createdAt
+ ),
+ auditOperation(log)
+ ]);
+ },
+ async saveAdmissionRecord(record, log = null) {
+ const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
+ `INSERT INTO admission_records (
+ id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
+ JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
+ )];
+ if (log) operations.push(auditOperation(log));
+ await transaction(operations);
+ },
+ async saveAdmissionRecords(records, log = null) {
+ const operations = [];
+ for (const record of records) {
+ operations.push(operation('DELETE FROM admission_records WHERE id = ?', record.id));
+ operations.push(operation(
+ `INSERT INTO admission_records (
+ id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
+ JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
+ ));
+ }
+ if (log) operations.push(auditOperation(log));
+ await transaction(operations);
+ },
async saveSchool(school, isNew, log) {
const change = isNew
? operation(
diff --git a/package.json b/package.json
index c95e77d..50c312d 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
- "test": "node tests/cache.test.mjs && node tests/system.test.mjs",
+ "test": "node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
diff --git a/server.mjs b/server.mjs
index fdbceee..855bbc6 100644
--- a/server.mjs
+++ b/server.mjs
@@ -8,6 +8,7 @@ import { createDatabase } from './database.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
import { createAdminRoutes } from './src/routes/admin.routes.mjs';
import { createCandidateRoutes } from './src/routes/candidate.routes.mjs';
+import { createAdmissionRoutes } from './src/routes/admission.routes.mjs';
import { createAuthRoutes } from './src/routes/auth.routes.mjs';
import { createPublicRoutes } from './src/routes/public.routes.mjs';
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
@@ -45,6 +46,7 @@ const staticFiles = new Set([
'/src/client/api.mjs',
'/src/client/admin-views.mjs',
'/src/client/candidate-views.mjs',
+ '/src/client/admission-views.mjs',
'/src/client/public-views.mjs',
'/src/client/state.mjs',
'/src/client/ui.mjs',
@@ -904,6 +906,7 @@ const routeContext = {
const handlePublic = createPublicRoutes(routeContext);
const handleAuth = createAuthRoutes(routeContext);
const handleCandidate = createCandidateRoutes(routeContext);
+const handleAdmission = createAdmissionRoutes(routeContext);
const handleAdmin = createAdminRoutes(routeContext);
async function serveStatic(response, pathname) {
@@ -929,6 +932,8 @@ const server = createServer(async (request, response) => {
if (authHandled !== false) return;
const candidateHandled = await handleCandidate(request, response, pathname);
if (candidateHandled !== false) return;
+ const admissionHandled = await handleAdmission(request, response, pathname);
+ if (admissionHandled !== false) return;
const adminHandled = await handleAdmin(request, response, pathname);
if (adminHandled !== false) return;
if (await serveStatic(response, pathname)) return;
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 2939d4c..28bf02d 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -44,6 +44,7 @@ export function createAdminViews(context) {
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'],
+ admissions: ['招生录取', '设置志愿窗口、审核招生计划,并按分数优先、遵循志愿执行投档、退档审核和补录。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
const allowedPages = adminNavForUser().map(item => item[0]);
@@ -57,7 +58,7 @@ export function createAdminViews(context) {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
schools: () => adminSchools(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
- 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity(data)
+ 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissions(data), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
} catch (error) { renderError(error); }
@@ -134,6 +135,14 @@ export function createAdminViews(context) {
return `${active.map(card).join('') || emptyState('没有进行中的考试', '新建考试,或在下方查阅已归档历史。')}
${archiveShelf}`;
}
+ function adminAdmissions(data) {
+ 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: '录取完成' };
+ 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 ? `` : ''}${data.schoolAccounts.length} 个 代上传招生计划
每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。指标分配可由招生学校提交后审核。
招生计划审核
${pendingPlans.length} 份待审投档与退档监督
超级管理员可见,任何管理员均不能修改考生志愿`;
+ }
+
function adminNotices(notices) {
return ``;
}
diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs
new file mode 100644
index 0000000..82a0adf
--- /dev/null
+++ b/src/client/admission-views.mjs
@@ -0,0 +1,34 @@
+export function createAdmissionViews(context) {
+ const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context;
+ const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
+
+ function shell(page, content, title, description) {
+ return `SCHOOL ADMISSION
${h(title)}
${h(description)}
${content} `;
+ }
+
+ async function renderAdmission(page) {
+ if (state.user?.role !== 'admission_school') return navigate('login');
+ if (!nav.some(item => item[0] === page)) page = 'dashboard';
+ const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
+ app.innerHTML = shell(page, '正在读取数据
', ...meta[page]);
+ try {
+ const endpoint = page === 'dashboard' ? 'context' : page;
+ const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
+ const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : placements(data);
+ app.innerHTML = shell(page, content, ...meta[page]);
+ } catch (error) { renderError(error); }
+ }
+
+ function dashboard(data) {
+ return `ADMISSION OFFICE${h(data.school.name)}
学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。
本校工作入口
${data.exams.length} 场启用志愿 `;
+ }
+
+ function plans(data) {
+ return `提交本校招生计划
每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。
`;
+ }
+
+ function placements(data) {
+ return `本校投档名单
显示投档所需的考生信息与当次成绩,不包含其余志愿。
${data.placements.length} 人 `;
+ }
+ return { renderAdmission };
+}
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index 8f7ff31..63330ab 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -20,7 +20,7 @@ export function createCandidateViews(context) {
const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
- ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'],
+ ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
['security', '账户安全', 'user']
];
function adminNavForUser() {
@@ -30,7 +30,7 @@ export function createCandidateViews(context) {
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
- return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
+ return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
}
function portalShell(role, page, content, title, description) {
@@ -54,6 +54,12 @@ export function createCandidateViews(context) {
function loadingPanel() {
return `正在读取数据
`;
}
+
+ function mountAdmissionProfileFields(profile = {}) {
+ const actions = app.querySelector('.profile-form .form-actions');
+ if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
+ actions.insertAdjacentHTML('beforebegin', ``);
+ }
function onboardingShell(stage, content) {
const passwordDone = stage !== 'password';
@@ -76,6 +82,7 @@ export function createCandidateViews(context) {
const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
+ mountAdmissionProfileFields(data.profile);
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); }
return;
@@ -87,23 +94,24 @@ export function createCandidateViews(context) {
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
+ admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try {
- const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
+ const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
state.pageData = data;
if (data.profile) state.profile = data.profile;
const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
- results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
+ results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
- if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
+ if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
} catch (error) { renderError(error); }
}
@@ -182,6 +190,20 @@ export function createCandidateViews(context) {
}).join('')}`;
}
+ function candidateAdmissions(data) {
+ const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', supplementary: '补录填报中', completed: '录取结束' };
+ if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
+ return `${data.notifications?.length ? `${h(data.notifications[0].payload.title)}${h(data.notifications[0].payload.message)}
${formatDate(data.notifications[0].createdAt, true)}` : ''}${data.admissions.map(item => {
+ const choices = item.preference?.payload?.choices || [];
+ const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null;
+ const options = item.plans.flatMap(plan => plan.categories.filter(category => category.remaining > 0 || choices.some(choice => choice.schoolId === plan.schoolId && choice.categoryCode === category.code)).map(category => ({ value: `${plan.schoolId}|${category.code}`, label: `${plan.schoolName} · ${category.name}`, specialtyType: category.specialtyType, remaining: category.remaining })));
+ const placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '';
+ const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
+ const progressIndex = item.status === 'supplementary' ? 1 : Math.max(0, progressSteps.indexOf(item.status));
+ return `
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}${h(phaseLabels[item.status] || item.status)}
${h(item.payload.progress || '等待录取工作更新')}
${item.placement ? `当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}
` : ''}${canFill ? `` : choices.length ? `已锁定志愿顺序${choices.map((choice, index) => { const option = options.find(entry => entry.value === `${choice.schoolId}|${choice.categoryCode}`); return `${index + 1}${h(option?.label || `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join('')}
` : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}`;
+ }).join('')}
`;
+ }
+
function candidateNotices(notices) {
return `${notices.map(notice => ``).join('')}
`;
}
diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs
index 2f02b9b..9e9bba9 100644
--- a/src/client/public-views.mjs
+++ b/src/client/public-views.mjs
@@ -20,12 +20,12 @@ export function createPublicViews(context) {
}
function publicHeader() {
- return ``;
+ return ``;
}
function renderHome() {
app.classList.remove('admin-readable');
- const { notices, exams, stats, organization } = state.publicData;
+ const { notices, exams, stats, organization, admissionAnnouncements = [] } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0];
@@ -37,6 +37,7 @@ export function createPublicViews(context) {
报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。
${topNotice ? `${h(topNotice.category)}${h(topNotice.title)}
${h(topNotice.summary)}
` : '暂无通知
'}${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
+ ${admissionAnnouncements.length ? `ADMISSION DISCLOSURE
录取结果公示
报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。
${admissionAnnouncements.map(announcement => ``).join('')}` : ''}
${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
报名号不会随考试改变,每场考试只新增一条报名记录。
${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}${item[1]}
${item[2]}
`).join('')}
`;
diff --git a/src/client/state.mjs b/src/client/state.mjs
index 90bbb2b..4932421 100644
--- a/src/client/state.mjs
+++ b/src/client/state.mjs
@@ -1,7 +1,7 @@
export const state = {
user: null,
profile: null,
- publicData: { organization: {}, notices: [], exams: [], stats: {} },
+ publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} },
permissions: [],
scopeLabel: '',
pageData: null,
diff --git a/src/client/ui.mjs b/src/client/ui.mjs
index 1152f1d..937581c 100644
--- a/src/client/ui.mjs
+++ b/src/client/ui.mjs
@@ -3,6 +3,8 @@ export const statusLabels = {
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员'
+ , admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
+ supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', final: '正式录取', unread: '未读'
};
export const icons = {
diff --git a/src/data/base.mjs b/src/data/base.mjs
index 01c770e..3efb7dc 100644
--- a/src/data/base.mjs
+++ b/src/data/base.mjs
@@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
const adminId = 'usr_admin';
const createdAt = nowIso();
return {
- meta: { version: 17, createdAt },
+ meta: { version: 18, createdAt },
settings: { selfRegistrationEnabled: false },
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
schools: [], classes: [],
@@ -83,6 +83,6 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
]
}],
- workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], auditLogs: []
+ workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: []
};
}
diff --git a/src/data/seed.mjs b/src/data/seed.mjs
index f640e63..9139ec1 100644
--- a/src/data/seed.mjs
+++ b/src/data/seed.mjs
@@ -165,6 +165,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
],
workflowInstances: [],
workflowActions: [],
+ admissionRecords: [],
auditLogs: [
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
]
diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs
index 4e3aa1c..62f98b7 100644
--- a/src/database/mysql-adapter.mjs
+++ b/src/database/mysql-adapter.mjs
@@ -167,6 +167,16 @@ export function createMysqlAdapter(context) {
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
metadataRows[0].schema_version = 17;
}
+ if (Number(metadataRows[0]?.schema_version || 1) < 18) {
+ await pool.query("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'candidate', 'admission_school') NOT NULL");
+ const [profileColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_types', 'specialty_certificate', 'policy_eligibility')");
+ const existingProfileColumns = new Set(profileColumns.map(item => item.Field));
+ if (!existingProfileColumns.has('specialty_types')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()) AFTER guardian_phone');
+ if (!existingProfileColumns.has('specialty_certificate')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_certificate VARCHAR(255) NULL AFTER specialty_types');
+ if (!existingProfileColumns.has('policy_eligibility')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN policy_eligibility VARCHAR(255) NULL AFTER specialty_certificate');
+ await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
+ metadataRows[0].schema_version = 18;
+ }
if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed();
const connection = await pool.getConnection();
@@ -349,7 +359,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction();
const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
- VALUES (1, 17, ?, ?, ?)
+ VALUES (1, 18, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
diff --git a/src/database/schema.mjs b/src/database/schema.mjs
index 01bccfc..2ac2e6b 100644
--- a/src/database/schema.mjs
+++ b/src/database/schema.mjs
@@ -47,7 +47,7 @@ export const sqliteSchema = `
username TEXT NOT NULL UNIQUE,
candidate_number TEXT UNIQUE,
password_hash TEXT NOT NULL,
- role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
+ role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
@@ -90,6 +90,9 @@ export const sqliteSchema = `
postal_code TEXT,
guardian_name TEXT,
guardian_phone TEXT,
+ specialty_types TEXT NOT NULL DEFAULT '[]',
+ specialty_certificate TEXT,
+ policy_eligibility TEXT,
profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)),
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
review_note TEXT,
@@ -426,6 +429,18 @@ export const sqliteSchema = `
created_at TEXT NOT NULL
) STRICT;
+ CREATE TABLE IF NOT EXISTS admission_records (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification')),
+ exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
+ school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
+ status TEXT NOT NULL,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ ) STRICT;
+
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
@@ -446,6 +461,7 @@ export const sqliteSchema = `
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
+ CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
BEFORE INSERT ON results
@@ -536,7 +552,7 @@ export const mysqlSchema = [
username VARCHAR(100) NOT NULL,
candidate_number VARCHAR(120) NULL,
password_hash VARCHAR(255) NOT NULL,
- role ENUM('admin', 'candidate') NOT NULL,
+ role ENUM('admin', 'candidate', 'admission_school') NOT NULL,
admin_level ENUM('super', 'school', 'class') NULL,
school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL,
@@ -586,6 +602,9 @@ export const mysqlSchema = [
postal_code VARCHAR(20) NULL,
guardian_name VARCHAR(100) NULL,
guardian_phone VARCHAR(60) NULL,
+ specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()),
+ specialty_certificate VARCHAR(255) NULL,
+ policy_eligibility VARCHAR(255) NULL,
profile_completed BOOLEAN NOT NULL DEFAULT FALSE,
status ENUM('pending', 'approved', 'rejected') NOT NULL,
review_note VARCHAR(500) NULL,
@@ -987,6 +1006,22 @@ export const mysqlSchema = [
CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
+ `CREATE TABLE IF NOT EXISTS admission_records (
+ id VARCHAR(64) NOT NULL,
+ kind ENUM('setting', 'plan', 'preference', 'placement', 'notification') NOT NULL,
+ exam_id VARCHAR(64) NOT NULL,
+ user_id VARCHAR(64) NULL,
+ school_id VARCHAR(64) NULL,
+ status VARCHAR(40) NOT NULL,
+ payload_json JSON NOT NULL,
+ created_at VARCHAR(35) NOT NULL,
+ updated_at VARCHAR(35) NOT NULL,
+ PRIMARY KEY (id),
+ KEY idx_admission_records_lookup (kind, exam_id, school_id, user_id, status),
+ CONSTRAINT fk_admission_record_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_admission_record_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_admission_record_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NULL,
diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs
index 8916174..98f6331 100644
--- a/src/database/sqlite-adapter.mjs
+++ b/src/database/sqlite-adapter.mjs
@@ -32,6 +32,8 @@ export function createSqliteAdapter(context) {
ensureColumns('users', [
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'],
+ ['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'],
+ ['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
@@ -39,7 +41,8 @@ export function createSqliteAdapter(context) {
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'],
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
- ['district_code', 'TEXT'], ['district_name', 'TEXT']
+ ['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
+ ['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
]);
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
ensureColumns('exams', [
@@ -68,6 +71,32 @@ export function createSqliteAdapter(context) {
ensureColumns('admit_card_subjects', [
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
]);
+ if (tableExists('users')) {
+ const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || '';
+ if (!usersSql.includes('admission_school')) {
+ connection.exec(`
+ PRAGMA foreign_keys = OFF;
+ BEGIN IMMEDIATE;
+ CREATE TABLE users_v18 (
+ id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
+ admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
+ school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
+ active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
+ totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT,
+ totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT,
+ archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL
+ ) STRICT;
+ INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
+ active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'),
+ totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users;
+ DROP TABLE users;
+ ALTER TABLE users_v18 RENAME TO users;
+ COMMIT;
+ PRAGMA foreign_keys = ON;
+ `);
+ }
+ }
if (tableExists('workflow_definitions')) {
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
if (!definitionSql.includes('candidate_account_batch')) {
@@ -246,13 +275,10 @@ export function createSqliteAdapter(context) {
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
- connection.exec(`
- ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1));
- ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT;
- ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]';
- ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER;
- UPDATE schema_metadata SET schema_version = 17 WHERE id = 1;
- `);
+ connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
+ }
+ if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
+ connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed();
@@ -414,7 +440,7 @@ export function createSqliteAdapter(context) {
try {
connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
- VALUES (1, 17, ?, ?, ?)
+ VALUES (1, 18, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 3ca37f6..a40e26f 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -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') {
diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs
new file mode 100644
index 0000000..5e0ad78
--- /dev/null
+++ b/src/routes/admission.routes.mjs
@@ -0,0 +1,85 @@
+import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
+
+function normalizeCategories(input, cleanText) {
+ const source = Array.isArray(input) ? input : [];
+ return source.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);
+}
+
+export function createAdmissionRoutes(context) {
+ const { database, readDb, sendJson, sendError, readJson, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
+
+ async function handleAdmission(request, response, pathname) {
+ if (!pathname.startsWith('/api/admission/')) return false;
+ const user = await requireUser(request, response, 'admission_school');
+ if (!user) return true;
+ const db = await readDb();
+ const school = db.schools.find(item => item.id === user.schoolId && item.active);
+ if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
+
+ if (request.method === 'GET' && pathname === '/api/admission/context') {
+ return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
+ }
+ if (request.method === 'GET' && pathname === '/api/admission/plans') {
+ const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan) }));
+ return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt) });
+ }
+ if (request.method === 'POST' && pathname === '/api/admission/plans') {
+ const body = await readJson(request);
+ const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
+ if (!exam) return sendError(response, 404, '考试不存在或已经归档');
+ const categories = normalizeCategories(body.categories, cleanText);
+ if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
+ if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.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 (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
+ const now = nowIso();
+ const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
+ Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
+ await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
+ return sendJson(response, existing ? 200 : 201, { ok: true, plan });
+ }
+ if (request.method === 'GET' && pathname === '/api/admission/placements') {
+ const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
+ const account = db.users.find(entry => entry.id === item.userId) || {};
+ const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
+ const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId);
+ const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => {
+ const exam = db.exams.find(entry => entry.id === item.examId);
+ return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
+ });
+ return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [], specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, results };
+ });
+ return sendJson(response, 200, { ok: true, school, placements });
+ }
+ const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
+ if (request.method === 'PATCH' && placementMatch) {
+ const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
+ if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
+ const body = await readJson(request);
+ const decision = cleanText(body.decision, 30);
+ const note = cleanText(body.note, 500);
+ if (decision === 'accept') placement.status = 'admitted';
+ else if (decision === 'withdraw') {
+ if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
+ placement.status = 'withdrawal_pending';
+ placement.payload.withdrawalReason = note;
+ } else return sendError(response, 400, '请选择接收或申请退档');
+ placement.payload.schoolDecisionNote = note;
+ placement.updatedAt = nowIso();
+ await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
+ return sendJson(response, 200, { ok: true, placement });
+ }
+ return sendError(response, 404, '招生学校功能接口不存在');
+ }
+
+ return handleAdmission;
+}
diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs
index 895c9e1..5ad02d8 100644
--- a/src/routes/candidate.routes.mjs
+++ b/src/routes/candidate.routes.mjs
@@ -1,4 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
+import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
export function createCandidateRoutes(context) {
const {
@@ -81,8 +82,9 @@ export function createCandidateRoutes(context) {
}
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request);
- const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
+ const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone', 'specialtyCertificate', 'policyEligibility'];
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
+ profile.specialtyTypes = [...new Set(String(body.specialtyTypes || '').split(/[,,]/).map(item => cleanText(item, 40)).filter(Boolean))].slice(0, 10);
const region = resolveRegion(body);
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
Object.assign(profile, region);
@@ -151,6 +153,44 @@ export function createCandidateRoutes(context) {
}, { ttlSeconds: resultsCacheTtlSeconds });
return sendJson(response, 200, payload);
}
+ if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
+ const settings = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
+ const exam = db.exams.find(item => item.id === setting.examId);
+ const round = Number(setting.payload?.round || 1);
+ const preference = activePreference(db, setting.examId, user.id, round);
+ const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
+ const plans = approvedPlans(db, setting.examId).map(plan => ({
+ id: plan.id, schoolId: plan.schoolId, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '',
+ categories: remainingPlanQuota(db, plan)
+ }));
+ return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id) };
+ }).filter(item => item.exam);
+ const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
+ return sendJson(response, 200, { ok: true, admissions: settings, notifications });
+ }
+ const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
+ if (request.method === 'PUT' && preferenceMatch) {
+ const setting = admissionSetting(db, preferenceMatch[1]);
+ if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
+ if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
+ const now = Date.now();
+ if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
+ if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
+ if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
+ const body = await readJson(request);
+ const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
+ const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40) }));
+ if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
+ if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同一学校和招生类别不能重复填报');
+ const plans = approvedPlans(db, setting.examId);
+ if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode)))) return sendError(response, 400, '志愿中包含未审核通过的学校或招生类别');
+ const round = Number(setting.payload.round || 1);
+ const nowValue = nowIso();
+ const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
+ Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue } });
+ await database.saveAdmissionRecord(preference);
+ return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' });
+ }
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
if (request.method === 'POST' && scoreAppealMatch) {
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs
index 72062cb..d22e9b3 100644
--- a/src/routes/public.routes.mjs
+++ b/src/routes/public.routes.mjs
@@ -1,4 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
+import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs';
export function createPublicRoutes(context) {
const {
@@ -57,7 +58,8 @@ export function createPublicRoutes(context) {
const db = await readDb();
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
- return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
+ const admissionAnnouncements = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', completedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }));
+ return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, admissionAnnouncements, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
});
return sendJson(response, 200, payload);
}
diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs
new file mode 100644
index 0000000..8cf2580
--- /dev/null
+++ b/src/services/volunteer-admission.mjs
@@ -0,0 +1,127 @@
+export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'supplementary', 'completed']);
+
+export function admissionRecords(db, kind, examId = null) {
+ return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
+}
+
+export function admissionSetting(db, examId) {
+ return admissionRecords(db, 'setting', examId)[0] || null;
+}
+
+export function candidateTotalScore(db, examId, userId) {
+ const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
+ if (!registration) return null;
+ const results = db.results.filter(item => item.registrationId === registration.id && item.published);
+ if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null;
+ return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
+}
+
+export function activePreference(db, examId, userId, round) {
+ return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
+}
+
+export function approvedPlans(db, examId) {
+ return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
+}
+
+export function planSummary(plan) {
+ const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
+ return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
+}
+
+export function publicAdmissionRows(db, examId) {
+ return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => {
+ const user = db.users.find(entry => entry.id === item.userId) || {};
+ const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
+ const school = db.schools.find(entry => entry.id === item.schoolId) || {};
+ return {
+ registrationNumber: user.candidateNumber || '',
+ name: profile.name || user.displayName || '',
+ totalScore: Number(item.payload?.totalScore || 0),
+ admittedSchool: school.name || '',
+ categoryName: item.payload?.categoryName || '',
+ idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '',
+ phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : ''
+ };
+ }).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
+}
+
+function categoryKey(schoolId, code) {
+ return `${schoolId}|${code}`;
+}
+
+export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
+ const examId = setting.examId;
+ const round = Number(setting.payload?.round || 1);
+ const plans = approvedPlans(db, examId);
+ const categories = new Map();
+ for (const plan of plans) for (const category of plan.payload?.categories || []) {
+ categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
+ }
+
+ const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn');
+ const occupied = new Map();
+ const occupiedIndicators = new Map();
+ const occupiedGeneral = new Map();
+ for (const placement of existing) {
+ const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
+ occupied.set(key, (occupied.get(key) || 0) + 1);
+ if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
+ const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
+ occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
+ } else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
+ }
+
+ const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted');
+ const candidates = preferences.map(preference => {
+ const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
+ const account = db.users.find(item => item.id === preference.userId) || {};
+ return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) };
+ }).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)))
+ .sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
+
+ const created = [];
+ for (const candidate of candidates) {
+ const specialtyTypes = Array.isArray(candidate.profile.specialtyTypes) ? candidate.profile.specialtyTypes : [];
+ for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
+ const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
+ if (!target) continue;
+ const { category } = target;
+ if (category.specialtyType && !specialtyTypes.includes(category.specialtyType)) continue;
+ const key = categoryKey(choice.schoolId, choice.categoryCode);
+ if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
+ const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
+ let quotaBucket = null;
+ if (allocation) {
+ const indicatorKey = `${key}|${candidate.profile.schoolId}`;
+ if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
+ quotaBucket = `indicator:${candidate.profile.schoolId}`;
+ occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
+ }
+ }
+ if (!quotaBucket) {
+ const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
+ if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
+ quotaBucket = 'general';
+ occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
+ }
+ occupied.set(key, (occupied.get(key) || 0) + 1);
+ created.push({
+ id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
+ status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
+ round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
+ totalScore: candidate.score, quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
+ }
+ });
+ break;
+ }
+ }
+ return created;
+}
+
+export function remainingPlanQuota(db, plan) {
+ return (plan.payload?.categories || []).map(category => {
+ const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn').length;
+ return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
+ });
+}
diff --git a/styles.css b/styles.css
index 8976ed8..e1783a7 100644
--- a/styles.css
+++ b/styles.css
@@ -581,3 +581,15 @@ button:disabled { cursor: not-allowed; opacity: .5; }
@media (max-width: 620px) {
.scope-banner { align-items:flex-start; }.candidate-flow-note { grid-template-columns:1fr; }.segment-option { grid-template-columns:18px 52px 1fr; }.segment-value,.segment-width { grid-column:2/-1; }.workflow-step-row { grid-template-columns:28px 1fr 26px; }.workflow-step-row > i { width:28px;height:28px; }.workflow-step-row select { grid-column:2/3; }.workflow-owner { grid-template-columns:1fr; }.center-card dl { grid-template-columns:1fr; }.number-rule-layout { display:block; }.rule-preview { margin-top:14px; }.center-summary,.center-metrics,.center-profile,.batch-number-panel form,.room-editor-grid,.flow-center-snapshot dl { grid-template-columns:1fr; }.center-dossier > header,.center-dossier > footer,.center-form-section > header,.approval-callout { align-items:flex-start; flex-direction:column; }.center-dossier > header > div:last-child { flex-wrap:wrap; }.room-editor-grid .room-notes { grid-column:auto; }.onboarding-page { display:block; }.onboarding-identity { position:relative; min-height:auto; padding:25px 20px; }.onboarding-identity > span { margin-top:35px; }.onboarding-identity > p { display:none; }.onboarding-identity > button { position:absolute; top:26px; right:20px; }.onboarding-steps { margin-top:30px; }.onboarding-steps > div { min-height:58px; }.onboarding-work { padding:30px 14px 45px; }.profile-form .wide-field { grid-column:auto; }.registration-policy { align-items:flex-start; flex-direction:column; }.registration-policy form { width:100%; justify-content:space-between; }.account-number-principle { grid-template-columns:1fr; }.account-number-principle span { grid-row:auto; writing-mode:horizontal-tb; }
}
+
+/* 中考志愿与招生录取:唯一强调元素是贯穿全流程的进度轨道。 */
+.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
+.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
+.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
+.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
+.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
+.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
+.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; }
+.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
+@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
+@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs
new file mode 100644
index 0000000..b97db23
--- /dev/null
+++ b/tests/admission.test.mjs
@@ -0,0 +1,62 @@
+import assert from 'node:assert/strict';
+import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs';
+
+const now = '2026-07-21T08:00:00.000Z';
+let sequence = 0;
+const db = {
+ users: [
+ { id: 'u-high', candidateNumber: '20260001', displayName: '高分考生' },
+ { id: 'u-low', candidateNumber: '20260002', displayName: '次高考生' },
+ { id: 'u-sport', candidateNumber: '20260003', displayName: '特长考生' }
+ ],
+ candidateProfiles: [
+ { userId: 'u-high', name: '高分考生', schoolId: 'source-a', idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
+ { userId: 'u-low', name: '次高考生', schoolId: 'source-b', idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
+ { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyTypes: ['田径'] }
+ ],
+ schools: [
+ { id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
+ { id: 'target-a', name: '第一中学' }, { id: 'target-b', name: '第二中学' }
+ ],
+ registrations: [
+ { id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
+ { id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
+ { id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'] }
+ ],
+ results: [
+ { registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
+ { registrationId: 'r-low', subjectId: 'cn', score: 118, published: true }, { registrationId: 'r-low', subjectId: 'math', score: 126, published: true },
+ { registrationId: 'r-sport', subjectId: 'cn', score: 105, published: true }, { registrationId: 'r-sport', subjectId: 'math', score: 110, published: true }
+ ],
+ admissionRecords: [
+ { id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
+ { id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
+ { code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
+ { code: 'sport', name: '田径特长生', quota: 1, specialtyType: '田径', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
+ ] } },
+ { id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
+ { id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
+ { id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport' }] } }
+ ]
+};
+
+const setting = { examId: 'exam', payload: { round: 1 } };
+const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
+assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
+assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
+assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
+assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
+assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
+
+db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
+const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
+assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
+assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
+const publicRows = publicAdmissionRows(db, 'exam');
+assert.equal(publicRows[0].registrationNumber, '20260001', '公示必须公开报名号');
+assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名');
+assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩');
+assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校');
+assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
+
+console.log('志愿投档、指标名额与脱敏公示测试通过');
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 358772b..2c7d944 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -221,7 +221,7 @@ try {
inspector.close();
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
- assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构');
+ assert.equal(schemaVersion, 18, '志愿填报与招生录取应使用 v18 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');