Add admission function #1

Merged
biss merged 5 commits from Add-Admission-Function into master 2026-07-21 15:11:56 +08:00
24 changed files with 770 additions and 30 deletions
Showing only changes of commit ae528b20e9 - Show all commits
+15
View File
@@ -256,6 +256,21 @@ npm run seed-test-data:mysql -- --force
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite` 强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`
## 中考志愿填报与招生录取
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。
数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`
## 手动测试数据账号 ## 手动测试数据账号
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建: 测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
+59
View File
@@ -1,6 +1,7 @@
import { api } from './src/client/api.mjs'; import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs'; import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-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 { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs'; import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.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 { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
function navigate(route) { function navigate(route) {
location.hash = route; location.hash = route;
@@ -74,6 +76,7 @@ async function renderRoute() {
else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page); else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page); else if (section === 'admin') await renderAdmin(page);
else if (section === 'admission_school') await renderAdmission(page);
else navigate('home'); else navigate('home');
} }
@@ -181,6 +184,35 @@ document.addEventListener('click', async event => {
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return; setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
} }
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; 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)) { if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
event.preventDefault(); event.preventDefault();
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value; 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') { } else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); 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') { } else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -548,6 +586,27 @@ document.addEventListener('submit', async event => {
const body = formObject(form); const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body }); await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); 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') { } else if (kind === 'school-form') {
const body = formObject(form); body.active = form.active.checked; 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 }); await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
+69 -3
View File
@@ -35,6 +35,7 @@ export const relationalTables = [
'workflow_steps', 'workflow_steps',
'workflow_instances', 'workflow_instances',
'workflow_actions', 'workflow_actions',
'admission_records',
'audit_logs' 'audit_logs'
]; ];
@@ -44,7 +45,7 @@ function validateState(state, source = '数据库') {
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
'admissionNumberRules', 'arrangementPlans', 'admissionNumberRules', 'arrangementPlans',
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' 'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs'
]; ];
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
throw new Error(`${source}中的应用数据格式无效`); throw new Error(`${source}中的应用数据格式无效`);
@@ -59,7 +60,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( 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, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() 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) { for (const log of state.auditLogs) {
add( add(
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
@@ -507,6 +518,9 @@ function stateFromRows(rows) {
postalCode: row.postal_code || '', postalCode: row.postal_code || '',
guardianName: row.guardian_name || '', guardianName: row.guardian_name || '',
guardianPhone: row.guardian_phone || '', 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), profileCompleted: Boolean(row.profile_completed),
status: row.status, status: row.status,
reviewNote: row.review_note || '', reviewNote: row.review_note || '',
@@ -738,6 +752,17 @@ function stateFromRows(rows) {
toAssigneeId: row.to_assignee_id, toAssigneeId: row.to_assignee_id,
createdAt: row.created_at 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 => ({ auditLogs: rows.auditLogs.map(row => ({
id: row.id, id: row.id,
actorId: row.actor_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(), 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(), 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(), 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() 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'), 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'), 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'), 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') 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 = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?, 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 = ? profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), 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.address), optional(profile.schoolId),
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, 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.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 optional(profile.reviewerId), profile.updatedAt, profile.id
), ),
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId)
@@ -1186,6 +1215,43 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) 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) { async saveSchool(school, isNew, log) {
const change = isNew const change = isNew
? operation( ? operation(
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.mjs", "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", "test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs", "reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs", "seed-test-data": "node scripts/import-test-data.mjs",
+5
View File
@@ -8,6 +8,7 @@ import { createDatabase } from './database.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
import { createAdminRoutes } from './src/routes/admin.routes.mjs'; import { createAdminRoutes } from './src/routes/admin.routes.mjs';
import { createCandidateRoutes } from './src/routes/candidate.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 { createAuthRoutes } from './src/routes/auth.routes.mjs';
import { createPublicRoutes } from './src/routes/public.routes.mjs'; import { createPublicRoutes } from './src/routes/public.routes.mjs';
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.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/api.mjs',
'/src/client/admin-views.mjs', '/src/client/admin-views.mjs',
'/src/client/candidate-views.mjs', '/src/client/candidate-views.mjs',
'/src/client/admission-views.mjs',
'/src/client/public-views.mjs', '/src/client/public-views.mjs',
'/src/client/state.mjs', '/src/client/state.mjs',
'/src/client/ui.mjs', '/src/client/ui.mjs',
@@ -904,6 +906,7 @@ const routeContext = {
const handlePublic = createPublicRoutes(routeContext); const handlePublic = createPublicRoutes(routeContext);
const handleAuth = createAuthRoutes(routeContext); const handleAuth = createAuthRoutes(routeContext);
const handleCandidate = createCandidateRoutes(routeContext); const handleCandidate = createCandidateRoutes(routeContext);
const handleAdmission = createAdmissionRoutes(routeContext);
const handleAdmin = createAdminRoutes(routeContext); const handleAdmin = createAdminRoutes(routeContext);
async function serveStatic(response, pathname) { async function serveStatic(response, pathname) {
@@ -929,6 +932,8 @@ const server = createServer(async (request, response) => {
if (authHandled !== false) return; if (authHandled !== false) return;
const candidateHandled = await handleCandidate(request, response, pathname); const candidateHandled = await handleCandidate(request, response, pathname);
if (candidateHandled !== false) return; if (candidateHandled !== false) return;
const admissionHandled = await handleAdmission(request, response, pathname);
if (admissionHandled !== false) return;
const adminHandled = await handleAdmin(request, response, pathname); const adminHandled = await handleAdmin(request, response, pathname);
if (adminHandled !== false) return; if (adminHandled !== false) return;
if (await serveStatic(response, pathname)) return; if (await serveStatic(response, pathname)) return;
File diff suppressed because one or more lines are too long
+34
View File
@@ -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 `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav>${nav.map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
}
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, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...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 `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button></section></div>`;
}
function plans(data) {
return `<section class="panel admission-plan-console"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId">${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生类别 *</span><textarea name="categoriesText" rows="5" required placeholder="普通生 | 120 |&#10;体育特长生 | 8 | 田径"></textarea></label><label><span>计划说明</span><textarea name="note" rows="2"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table><thead><tr><th>考试</th><th>类别计划</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${data.plans.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)}`).join('<br>')}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div></section>`;
}
function placements(data) {
return `<section class="panel data-panel"><div class="panel-title"><div><h2>本校投档名单</h2><p>显示投档所需的考生信息与当次成绩,不包含其余志愿。</p></div><span>${data.placements.length} 人</span></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>审核</th></tr></thead><tbody>${data.placements.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small></td><td>${h((item.candidate.specialtyTypes || []).join('、') || '普通生')}<small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>总分 ${h(item.payload.totalScore)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写特殊理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div></section>`;
}
return { renderAdmission };
}
+27 -5
View File
@@ -20,7 +20,7 @@ export function createCandidateViews(context) {
const candidateNav = [ const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], ['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'] ['security', '账户安全', 'user']
]; ];
function adminNavForUser() { 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]; 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']]; 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]; 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) { function portalShell(role, page, content, title, description) {
@@ -54,6 +54,12 @@ export function createCandidateViews(context) {
function loadingPanel() { function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`; return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
} }
function mountAdmissionProfileFields(profile = {}) {
const actions = app.querySelector('.profile-form .form-actions');
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
actions.insertAdjacentHTML('beforebegin', `<div class="form-section-title" data-admission-profile-fields><span>04</span><div><h2>中考招生资格</h2><p>用于普通生、各类特长生和政策性计划资格校验;多个特长类型用逗号分隔。</p></div></div><div class="form-grid"><label><span>特长生类型</span><input name="specialtyTypes" value="${h((profile.specialtyTypes || []).join('、'))}" placeholder="例如:田径、声乐"></label><label><span>特长证明编号</span><input name="specialtyCertificate" value="${h(profile.specialtyCertificate || '')}" placeholder="证书或材料编号"></label><label class="wide-field"><span>政策资格说明</span><input name="policyEligibility" value="${h(profile.policyEligibility || '')}" placeholder="例如:指标生资格已核验"></label></div>`);
}
function onboardingShell(stage, content) { function onboardingShell(stage, content) {
const passwordDone = stage !== 'password'; const passwordDone = stage !== 'password';
@@ -76,6 +82,7 @@ export function createCandidateViews(context) {
const data = await api('/api/candidate/profile'); const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile; state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true)); app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
mountAdmissionProfileFields(data.profile);
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); } } catch (error) { renderError(error); }
return; return;
@@ -87,23 +94,24 @@ export function createCandidateViews(context) {
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'], notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。'] security: ['账户安全', '使用当前密码设置新的登录密码。']
}; };
if (!meta[page]) page = 'dashboard'; if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try { 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}`); const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
state.pageData = data; state.pageData = data;
if (data.profile) state.profile = data.profile; if (data.profile) state.profile = data.profile;
const content = { const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), 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](); }[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[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); } } catch (error) { renderError(error); }
} }
@@ -182,6 +190,20 @@ export function createCandidateViews(context) {
}).join('')}</div>`; }).join('')}</div>`;
} }
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 ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></section>` : ''}<div class="admission-candidate-list">${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 `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>按顺序填写志愿</strong><small>系统按“分数优先、遵循志愿”依次检索;仅你本人可保存和修改。</small></div><span>最多 ${h(item.payload.maxChoices)} 个</span></div><div class="preference-choice-list">${Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => { const selected = choices[index] ? `${choices[index].schoolId}|${choices[index].categoryCode}` : ''; return `<label><b>${index + 1}</b><select name="choices"><option value="">${index ? '可不填' : '请选择第一志愿'}</option>${options.map(option => `<option value="${h(option.value)}" ${option.value === selected ? 'selected' : ''}>${h(option.label)}${option.specialtyType ? `(限 ${h(option.specialtyType)}` : ''} · 余 ${h(option.remaining)}</option>`).join('')}</select></label>`; }).join('')}</div><button class="solid-button" type="submit"></button></form>` : choices.length ? `<div class="locked-preferences"><strong></strong>${choices.map((choice, index) => { const option = options.find(entry => entry.value === `${choice.schoolId}|${choice.categoryCode}`); return `<span><b>${index + 1}</b>${h(option?.label || `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('')}</div>` : '<div class="read-only-callout"></div>'}</section>`;
}).join('')}</div>`;
}
function candidateNotices(notices) { function candidateNotices(notices) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`; return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
} }
+3 -2
View File
@@ -20,12 +20,12 @@ export function createPublicViews(context) {
} }
function publicHeader() { function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`; return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#home-admissions" data-action="scroll-to" data-target="home-admissions">录取公示</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
} }
function renderHome() { function renderHome() {
app.classList.remove('admin-readable'); 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 siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0]; const topNotice = notices[0];
@@ -37,6 +37,7 @@ export function createPublicViews(context) {
</div> </div>
</section> </section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2></h2></div><p></p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}"> ${icons.arrow}</button></footer>` : '<p></p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state"></div>'}</div></div></section> <section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2></h2></div><p></p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}"> ${icons.arrow}</button></footer>` : '<p></p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state"></div>'}</div></div></section>
${admissionAnnouncements.length ? `<section class="content-section public-admission-section" id="home-admissions"><div class="section-heading"><div><p class="overline">ADMISSION DISCLOSURE</p><h2>录取结果公示</h2></div><p>报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。</p></div>${admissionAnnouncements.map(announcement => `<article class="panel public-admission-board"><header><div><span>${formatDate(announcement.completedAt)}</span><h3>${h(announcement.examName)}</h3></div><strong>${announcement.rows.length} 人录取</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th><th>身份核验</th></tr></thead><tbody>${announcement.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td><td class="mono">${h(row.idNumberMasked)}</td></tr>`).join('')}</tbody></table></div></article>`).join('')}</section>` : ''}
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2></h2></div><p></p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state"></div>'}</div></section> <section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2></h2></div><p></p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state"></div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2></h2></div><p></p></div><div class="flow-track">${[['01','',''],['02','',''],['03','',''],['04','',''],['05','','使']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section> <section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2></h2></div><p></p></div><div class="flow-track">${[['01','',''],['02','',''],['03','',''],['04','',''],['05','','使']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`; </main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
+1 -1
View File
@@ -1,7 +1,7 @@
export const state = { export const state = {
user: null, user: null,
profile: null, profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} }, publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} },
permissions: [], permissions: [],
scopeLabel: '', scopeLabel: '',
pageData: null, pageData: null,
+2
View File
@@ -3,6 +3,8 @@ export const statusLabels = {
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档', published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员' super: '超级管理员', school: '校级管理员', class: '班级管理员'
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', final: '正式录取', unread: '未读'
}; };
export const icons = { export const icons = {
+2 -2
View File
@@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
const adminId = 'usr_admin'; const adminId = 'usr_admin';
const createdAt = nowIso(); const createdAt = nowIso();
return { return {
meta: { version: 17, createdAt }, meta: { version: 18, createdAt },
settings: { selfRegistrationEnabled: false }, settings: { selfRegistrationEnabled: false },
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
schools: [], classes: [], schools: [], classes: [],
@@ -83,6 +83,6 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } { 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: []
}; };
} }
+1
View File
@@ -165,6 +165,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
], ],
workflowInstances: [], workflowInstances: [],
workflowActions: [], workflowActions: [],
admissionRecords: [],
auditLogs: [ auditLogs: [
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
] ]
+11 -1
View File
@@ -167,6 +167,16 @@ export function createMysqlAdapter(context) {
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1'); await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
metadataRows[0].schema_version = 17; 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) { if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -349,7 +359,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction(); await connection.beginTransaction();
const [insert] = await connection.execute(` const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) 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()]); `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) { if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
+37 -2
View File
@@ -47,7 +47,7 @@ export const sqliteSchema = `
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
candidate_number TEXT UNIQUE, candidate_number TEXT UNIQUE,
password_hash TEXT NOT NULL, 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')), admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
class_id TEXT REFERENCES school_classes(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, postal_code TEXT,
guardian_name TEXT, guardian_name TEXT,
guardian_phone 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)), profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)),
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
review_note TEXT, review_note TEXT,
@@ -426,6 +429,18 @@ export const sqliteSchema = `
created_at TEXT NOT NULL created_at TEXT NOT NULL
) STRICT; ) 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_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_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); 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_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_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_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 CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
BEFORE INSERT ON results BEFORE INSERT ON results
@@ -536,7 +552,7 @@ export const mysqlSchema = [
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
candidate_number VARCHAR(120) NULL, candidate_number VARCHAR(120) NULL,
password_hash VARCHAR(255) NOT 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, admin_level ENUM('super', 'school', 'class') NULL,
school_id VARCHAR(64) NULL, school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL,
@@ -586,6 +602,9 @@ export const mysqlSchema = [
postal_code VARCHAR(20) NULL, postal_code VARCHAR(20) NULL,
guardian_name VARCHAR(100) NULL, guardian_name VARCHAR(100) NULL,
guardian_phone VARCHAR(60) 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, profile_completed BOOLEAN NOT NULL DEFAULT FALSE,
status ENUM('pending', 'approved', 'rejected') NOT NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL,
review_note VARCHAR(500) 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_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 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`, ) 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 ( `CREATE TABLE IF NOT EXISTS audit_logs (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NULL, actor_id VARCHAR(64) NULL,
+35 -9
View File
@@ -32,6 +32,8 @@ export function createSqliteAdapter(context) {
ensureColumns('users', [ ensureColumns('users', [
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], ['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'], ['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'] ['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]); ]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); 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'], ['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'], ['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'], ['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('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
ensureColumns('exams', [ ensureColumns('exams', [
@@ -68,6 +71,32 @@ export function createSqliteAdapter(context) {
ensureColumns('admit_card_subjects', [ ensureColumns('admit_card_subjects', [
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"] ['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')) { if (tableExists('workflow_definitions')) {
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
if (!definitionSql.includes('candidate_account_batch')) { 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(); connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) { if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
connection.exec(` connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
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; if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]'; connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER;
UPDATE schema_metadata SET schema_version = 17 WHERE id = 1;
`);
} }
if (existingSystem && Number(existingSystem.app_version || 1) < 2) { if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
@@ -414,7 +440,7 @@ export function createSqliteAdapter(context) {
try { try {
connection.prepare(` connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) 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()); `).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); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT'); connection.exec('COMMIT');
+127
View File
@@ -1,5 +1,6 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs'; import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.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) { export function createAdminRoutes(context) {
const { const {
@@ -108,6 +109,16 @@ export function createAdminRoutes(context) {
return ''; 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) { async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false; if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin'); const user = await requireUser(request, response, 'admin');
@@ -128,6 +139,122 @@ export function createAdminRoutes(context) {
classes: db.classes 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)$/); const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
if (excelMatch && request.method === 'GET') { if (excelMatch && request.method === 'GET') {
+85
View File
@@ -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;
}
+41 -1
View File
@@ -1,4 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs'; import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
export function createCandidateRoutes(context) { export function createCandidateRoutes(context) {
const { const {
@@ -81,8 +82,9 @@ export function createCandidateRoutes(context) {
} }
if (request.method === 'PUT' && pathname === '/api/candidate/profile') { if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request); 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); 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); const region = resolveRegion(body);
if (!region) return sendError(response, 400, '请选择有效的省、市和区县'); if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
Object.assign(profile, region); Object.assign(profile, region);
@@ -151,6 +153,44 @@ export function createCandidateRoutes(context) {
}, { ttlSeconds: resultsCacheTtlSeconds }); }, { ttlSeconds: resultsCacheTtlSeconds });
return sendJson(response, 200, payload); 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$/); const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
if (request.method === 'POST' && scoreAppealMatch) { if (request.method === 'POST' && scoreAppealMatch) {
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published); const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
+3 -1
View File
@@ -1,4 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs'; import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs';
export function createPublicRoutes(context) { export function createPublicRoutes(context) {
const { const {
@@ -57,7 +58,8 @@ export function createPublicRoutes(context) {
const db = await readDb(); 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 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 })); 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); return sendJson(response, 200, payload);
} }
+127
View File
@@ -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) };
});
}
+12
View File
@@ -581,3 +581,15 @@ button:disabled { cursor: not-allowed; opacity: .5; }
@media (max-width: 620px) { @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; } .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; } }
+62
View File
@@ -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('志愿投档、指标名额与脱敏公示测试通过');
+1 -1
View File
@@ -221,7 +221,7 @@ try {
inspector.close(); inspector.close();
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在'); assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构'); assert.equal(schemaVersion, 18, '志愿填报与招生录取应使用 v18 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表'); assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏'); assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致'); assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');