diff --git a/README.md b/README.md index 621c5ca..991c93f 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,21 @@ npm run seed-test-data:mysql -- --force 强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。 +## 中考志愿填报与招生录取 + +系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下: + +1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。 +2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。 +3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。 +4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。 +5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 +6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。 + +公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。 + +数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 + ## 手动测试数据账号 测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建: diff --git a/app.js b/app.js index edc0d9b..3c5dadf 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,7 @@ import { api } from './src/client/api.mjs'; import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs'; import { createCandidateViews } from './src/client/candidate-views.mjs'; +import { createAdmissionViews } from './src/client/admission-views.mjs'; import { createPublicViews } from './src/client/public-views.mjs'; import { state } from './src/client/state.mjs'; import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs'; @@ -59,6 +60,7 @@ const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, pa const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); +const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand }); function navigate(route) { location.hash = route; @@ -74,6 +76,7 @@ async function renderRoute() { else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); + else if (section === 'admission_school') await renderAdmission(page); else navigate('home'); } @@ -181,6 +184,35 @@ document.addEventListener('click', async event => { setModal(`
${contentHtml}
`); return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } + if (action === 'admission-plan-review') { + const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null; + if (reviewNote == null) return; + await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } }); + toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute(); + } + if (action === 'admission-match') { + if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return; + const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' }); + toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute(); + } + if (action === 'admission-finalize') { + if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return; + const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' }); + await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute(); + } + if (action === 'admission-supplementary') { + const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00)', ''); + if (!preferenceEnd) return; + await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } }); + toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute(); + } + if (action === 'withdrawal-review') { + const approved = target.dataset.approved === 'true'; + const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', ''); + if (reviewNote == null) return; + await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } }); + toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute(); + } if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) { event.preventDefault(); const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value; @@ -527,6 +559,12 @@ document.addEventListener('submit', async event => { } else if (kind === 'candidate-profile') { const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); + } else if (kind === 'volunteer-preference') { + const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => { + const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode }; + }); + await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); + toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute(); } else if (kind === 'exam-registration') { const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); @@ -548,6 +586,27 @@ document.addEventListener('submit', async event => { const body = formObject(form); await api('/api/admin/admins', { method: 'POST', body }); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); + } else if (kind === 'admission-setting') { + const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); + await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body }); + toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute(); + } else if (kind === 'admission-account') { + await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) }); + form.reset(); toast('招生学校账号已创建'); renderRoute(); + } else if (kind === 'admission-plan' || kind === 'school-admission-plan') { + const body = formObject(form); + body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => { + const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim()); + const indicatorAllocations = indicators.split(/[,,]/).map(entry => { const [sourceSchoolId, count] = entry.split(':').map(item => item.trim()); return { sourceSchoolId, quota: Number(count) }; }).filter(item => item.sourceSchoolId && item.quota > 0); + return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations }; + }).filter(item => item.name && item.quota > 0); + if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划'); + await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body }); + toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute(); + } else if (kind === 'placement-review') { + const body = formObject(form); + await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body }); + toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute(); } else if (kind === 'school-form') { const body = formObject(form); body.active = form.active.checked; await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body }); diff --git a/database.mjs b/database.mjs index f8dcd38..99119db 100644 --- a/database.mjs +++ b/database.mjs @@ -35,6 +35,7 @@ export const relationalTables = [ 'workflow_steps', 'workflow_instances', 'workflow_actions', + 'admission_records', 'audit_logs' ]; @@ -44,7 +45,7 @@ function validateState(state, source = '数据库') { 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', 'admissionNumberRules', 'arrangementPlans', 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', - 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' + 'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs' ]; if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { throw new Error(`${source}中的应用数据格式无效`); @@ -59,7 +60,7 @@ export function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 17, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 18, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); @@ -335,6 +336,16 @@ export function buildSeedOperations(state) { ); } + for (const record of state.admissionRecords) { + add( + `INSERT INTO admission_records ( + id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.id, record.kind, record.examId, nullable(record.userId), nullable(record.schoolId), record.status, + JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt + ); + } + for (const log of state.auditLogs) { add( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', @@ -507,6 +518,9 @@ function stateFromRows(rows) { postalCode: row.postal_code || '', guardianName: row.guardian_name || '', guardianPhone: row.guardian_phone || '', + specialtyTypes: (() => { try { return JSON.parse(row.specialty_types || '[]'); } catch { return []; } })(), + specialtyCertificate: row.specialty_certificate || '', + policyEligibility: row.policy_eligibility || '', profileCompleted: Boolean(row.profile_completed), status: row.status, reviewNote: row.review_note || '', @@ -738,6 +752,17 @@ function stateFromRows(rows) { toAssigneeId: row.to_assignee_id, createdAt: row.created_at })), + admissionRecords: rows.admissionRecords.map(row => ({ + id: row.id, + kind: row.kind, + examId: row.exam_id, + userId: row.user_id || null, + schoolId: row.school_id || null, + status: row.status, + payload: (() => { try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; } })(), + createdAt: row.created_at, + updatedAt: row.updated_at + })), auditLogs: rows.auditLogs.map(row => ({ id: row.id, actorId: row.actor_id, @@ -779,6 +804,7 @@ function readSqliteRows(connection) { workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(), workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(), workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(), + admissionRecords: connection.prepare('SELECT * FROM admission_records ORDER BY created_at, id').all(), auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all() }; } @@ -815,6 +841,7 @@ async function readMysqlRows(connection) { workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'), workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'), workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'), + admissionRecords: await query('SELECT * FROM admission_records ORDER BY created_at, id'), auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC') }; } @@ -925,6 +952,7 @@ function createRepository({ client, location, read, transaction, close }) { province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?, school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?, + specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?, profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ? WHERE id = ?`, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), @@ -933,7 +961,8 @@ function createRepository({ client, location, read, transaction, close }) { optional(profile.address), optional(profile.schoolId), optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), - optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), + optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), JSON.stringify(profile.specialtyTypes || []), + optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) @@ -1186,6 +1215,43 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, + async createAdmissionSchoolAccount(user, log) { + await transaction([ + operation( + `INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admission_school', NULL, ?, NULL, ?, ?, ?)`, + user.id, user.username, user.passwordHash, user.schoolId, user.active === false ? 0 : 1, user.displayName, user.createdAt + ), + auditOperation(log) + ]); + }, + async saveAdmissionRecord(record, log = null) { + const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation( + `INSERT INTO admission_records ( + id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status, + JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt + )]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveAdmissionRecords(records, log = null) { + const operations = []; + for (const record of records) { + operations.push(operation('DELETE FROM admission_records WHERE id = ?', record.id)); + operations.push(operation( + `INSERT INTO admission_records ( + id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status, + JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt + )); + } + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, async saveSchool(school, isNew, log) { const change = isNew ? operation( diff --git a/package.json b/package.json index c95e77d..50c312d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "node server.mjs", - "test": "node tests/cache.test.mjs && node tests/system.test.mjs", + "test": "node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs", "test:cache": "node tests/cache.test.mjs", "reset-db": "node scripts/reset-dev-database.mjs", "seed-test-data": "node scripts/import-test-data.mjs", diff --git a/server.mjs b/server.mjs index fdbceee..855bbc6 100644 --- a/server.mjs +++ b/server.mjs @@ -8,6 +8,7 @@ import { createDatabase } from './database.mjs'; import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; import { createAdminRoutes } from './src/routes/admin.routes.mjs'; import { createCandidateRoutes } from './src/routes/candidate.routes.mjs'; +import { createAdmissionRoutes } from './src/routes/admission.routes.mjs'; import { createAuthRoutes } from './src/routes/auth.routes.mjs'; import { createPublicRoutes } from './src/routes/public.routes.mjs'; import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs'; @@ -45,6 +46,7 @@ const staticFiles = new Set([ '/src/client/api.mjs', '/src/client/admin-views.mjs', '/src/client/candidate-views.mjs', + '/src/client/admission-views.mjs', '/src/client/public-views.mjs', '/src/client/state.mjs', '/src/client/ui.mjs', @@ -904,6 +906,7 @@ const routeContext = { const handlePublic = createPublicRoutes(routeContext); const handleAuth = createAuthRoutes(routeContext); const handleCandidate = createCandidateRoutes(routeContext); +const handleAdmission = createAdmissionRoutes(routeContext); const handleAdmin = createAdminRoutes(routeContext); async function serveStatic(response, pathname) { @@ -929,6 +932,8 @@ const server = createServer(async (request, response) => { if (authHandled !== false) return; const candidateHandled = await handleCandidate(request, response, pathname); if (candidateHandled !== false) return; + const admissionHandled = await handleAdmission(request, response, pathname); + if (admissionHandled !== false) return; const adminHandled = await handleAdmin(request, response, pathname); if (adminHandled !== false) return; if (await serveStatic(response, pathname)) return; diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 2939d4c..28bf02d 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -44,6 +44,7 @@ export function createAdminViews(context) { flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'], + admissions: ['招生录取', '设置志愿窗口、审核招生计划,并按分数优先、遵循志愿执行投档、退档审核和补录。'], security: ['账户安全', '使用当前密码设置新的登录密码。'] }; const allowedPages = adminNavForUser().map(item => item[0]); @@ -57,7 +58,7 @@ export function createAdminViews(context) { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data), schools: () => adminSchools(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), - 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity(data) + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissions(data), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); } catch (error) { renderError(error); } @@ -134,6 +135,14 @@ export function createAdminViews(context) { return `
${active.map(card).join('') || emptyState('没有进行中的考试', '新建考试,或在下方查阅已归档历史。')}
${archiveShelf}`; } + function adminAdmissions(data) { + const selected = data.settings.find(item => item.status !== 'completed') || data.settings[0]; + const pendingPlans = data.plans.filter(item => item.status === 'pending'); + const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); + const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }; + return `
ADMISSION COMMAND

中考招生录取控制台

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

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

考试志愿设置

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

${selected ? `
` : ''}

招生学校账号

账号只能查看本校投档名单和本校计划。

${data.schoolAccounts.length} 个

代上传招生计划

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

招生计划审核

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

投档与退档监督

超级管理员可见,任何管理员均不能修改考生志愿
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.payload.totalScore)} 分 · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; + } + function adminNotices(notices) { return `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; } diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs new file mode 100644 index 0000000..82a0adf --- /dev/null +++ b/src/client/admission-views.mjs @@ -0,0 +1,34 @@ +export function createAdmissionViews(context) { + const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context; + const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']]; + + function shell(page, content, title, description) { + return `
招生学校/${h(title)}
${h((state.user?.displayName || '招').slice(0,1))}${h(state.user?.displayName)}招生学校账号

SCHOOL ADMISSION

${h(title)}

${h(description)}

${content}
`; + } + + async function renderAdmission(page) { + if (state.user?.role !== 'admission_school') return navigate('login'); + if (!nav.some(item => item[0] === page)) page = 'dashboard'; + const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] }; + app.innerHTML = shell(page, '
正在读取数据
', ...meta[page]); + try { + const endpoint = page === 'dashboard' ? 'context' : page; + const data = await api(`/api/admission/${endpoint}`); state.pageData = data; + const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : placements(data); + app.innerHTML = shell(page, content, ...meta[page]); + } catch (error) { renderError(error); } + } + + function dashboard(data) { + return `
ADMISSION OFFICE

${h(data.school.name)}

学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。

本校工作入口

${data.exams.length} 场启用志愿
`; + } + + function plans(data) { + return `

提交本校招生计划

每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。

${data.plans.map(plan => ``).join('') || ''}
考试类别计划状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人`).join('
')}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
`; + } + + function placements(data) { + return `

本校投档名单

显示投档所需的考生信息与当次成绩,不包含其余志愿。

${data.placements.length} 人
${data.placements.map(item => ``).join('') || ''}
考生资格当次成绩投档类别状态审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h((item.candidate.specialtyTypes || []).join('、') || '普通生')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}总分 ${h(item.payload.totalScore)}
${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}
暂无投档考生
`; + } + return { renderAdmission }; +} diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 8f7ff31..63330ab 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -20,7 +20,7 @@ export function createCandidateViews(context) { const candidateNav = [ ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], - ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'], + ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'], ['security', '账户安全', 'user'] ]; function adminNavForUser() { @@ -30,7 +30,7 @@ export function createCandidateViews(context) { if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security]; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security]; - return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; + return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; } function portalShell(role, page, content, title, description) { @@ -54,6 +54,12 @@ export function createCandidateViews(context) { function loadingPanel() { return `
正在读取数据
`; } + + function mountAdmissionProfileFields(profile = {}) { + const actions = app.querySelector('.profile-form .form-actions'); + if (!actions || app.querySelector('[data-admission-profile-fields]')) return; + actions.insertAdjacentHTML('beforebegin', `
04

中考招生资格

用于普通生、各类特长生和政策性计划资格校验;多个特长类型用逗号分隔。

`); + } function onboardingShell(stage, content) { const passwordDone = stage !== 'password'; @@ -76,6 +82,7 @@ export function createCandidateViews(context) { const data = await api('/api/candidate/profile'); state.pageData = data; state.profile = data.profile; app.innerHTML = onboardingShell('profile', candidateProfile(data, true)); + mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); } catch (error) { renderError(error); } return; @@ -87,23 +94,24 @@ export function createCandidateViews(context) { registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], + admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'], notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'], security: ['账户安全', '使用当前密码设置新的登录密码。'] }; if (!meta[page]) page = 'dashboard'; app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); try { - const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations'; + const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations'; const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`); state.pageData = data; if (data.profile) state.profile = data.profile; const content = { dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), - results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data) + results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('candidate', page, content, ...meta[page]); - if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); + if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); } } catch (error) { renderError(error); } } @@ -182,6 +190,20 @@ export function createCandidateViews(context) { }).join('')}`; } + function candidateAdmissions(data) { + const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', supplementary: '补录填报中', completed: '录取结束' }; + if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩'); + return `${data.notifications?.length ? `
${h(data.notifications[0].payload.title)}

${h(data.notifications[0].payload.message)}

${formatDate(data.notifications[0].createdAt, true)}
` : ''}
${data.admissions.map(item => { + const choices = item.preference?.payload?.choices || []; + const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null; + const options = item.plans.flatMap(plan => plan.categories.filter(category => category.remaining > 0 || choices.some(choice => choice.schoolId === plan.schoolId && choice.categoryCode === category.code)).map(category => ({ value: `${plan.schoolId}|${category.code}`, label: `${plan.schoolName} · ${category.name}`, specialtyType: category.specialtyType, remaining: category.remaining }))); + const placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || ''; + const progressSteps = ['filling', 'closed', 'school_review', 'completed']; + const progressIndex = item.status === 'supplementary' ? 1 : Math.max(0, progressSteps.indexOf(item.status)); + return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}${h(phaseLabels[item.status] || item.status)}

${h(item.payload.progress || '等待录取工作更新')}

${item.placement ? `
当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}
` : ''}${canFill ? `
按顺序填写志愿系统按“分数优先、遵循志愿”依次检索;仅你本人可保存和修改。
最多 ${h(item.payload.maxChoices)} 个
${Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => { const selected = choices[index] ? `${choices[index].schoolId}|${choices[index].categoryCode}` : ''; return ``; }).join('')}
` : choices.length ? `
已锁定志愿顺序${choices.map((choice, index) => { const option = options.find(entry => entry.value === `${choice.schoolId}|${choice.categoryCode}`); return `${index + 1}${h(option?.label || `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join('')}
` : '
当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}
`; + }).join('')}
`; + } + function candidateNotices(notices) { return `
${notices.map(notice => ``).join('')}
`; } diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 2f02b9b..9e9bba9 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -20,12 +20,12 @@ export function createPublicViews(context) { } function publicHeader() { - return `
${brand()}
`; + return `
${brand()}
`; } function renderHome() { app.classList.remove('admin-readable'); - const { notices, exams, stats, organization } = state.publicData; + const { notices, exams, stats, organization, admissionAnnouncements = [] } = state.publicData; const siteCopy = state.publicData.siteCopy || {}; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const topNotice = notices[0]; @@ -37,6 +37,7 @@ export function createPublicViews(context) {

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
+ ${admissionAnnouncements.length ? `

ADMISSION DISCLOSURE

录取结果公示

报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。

${admissionAnnouncements.map(announcement => `
${formatDate(announcement.completedAt)}

${h(announcement.examName)}

${announcement.rows.length} 人录取
${announcement.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别身份核验
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}${h(row.idNumberMasked)}
`).join('')}
` : ''}

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
`; diff --git a/src/client/state.mjs b/src/client/state.mjs index 90bbb2b..4932421 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -1,7 +1,7 @@ export const state = { user: null, profile: null, - publicData: { organization: {}, notices: [], exams: [], stats: {} }, + publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} }, permissions: [], scopeLabel: '', pageData: null, diff --git a/src/client/ui.mjs b/src/client/ui.mjs index 1152f1d..937581c 100644 --- a/src/client/ui.mjs +++ b/src/client/ui.mjs @@ -3,6 +3,8 @@ export const statusLabels = { published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档', open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', super: '超级管理员', school: '校级管理员', class: '班级管理员' + , admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中', + supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', final: '正式录取', unread: '未读' }; export const icons = { diff --git a/src/data/base.mjs b/src/data/base.mjs index 01c770e..3efb7dc 100644 --- a/src/data/base.mjs +++ b/src/data/base.mjs @@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) const adminId = 'usr_admin'; const createdAt = nowIso(); return { - meta: { version: 17, createdAt }, + meta: { version: 18, createdAt }, settings: { selfRegistrationEnabled: false }, organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, schools: [], classes: [], @@ -83,6 +83,6 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } ] }], - workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], auditLogs: [] + workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: [] }; } diff --git a/src/data/seed.mjs b/src/data/seed.mjs index f640e63..9139ec1 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -165,6 +165,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { ], workflowInstances: [], workflowActions: [], + admissionRecords: [], auditLogs: [ { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } ] diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 4e3aa1c..62f98b7 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -167,6 +167,16 @@ export function createMysqlAdapter(context) { await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1'); metadataRows[0].schema_version = 17; } + if (Number(metadataRows[0]?.schema_version || 1) < 18) { + await pool.query("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'candidate', 'admission_school') NOT NULL"); + const [profileColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_types', 'specialty_certificate', 'policy_eligibility')"); + const existingProfileColumns = new Set(profileColumns.map(item => item.Field)); + if (!existingProfileColumns.has('specialty_types')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()) AFTER guardian_phone'); + if (!existingProfileColumns.has('specialty_certificate')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_certificate VARCHAR(255) NULL AFTER specialty_types'); + if (!existingProfileColumns.has('policy_eligibility')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN policy_eligibility VARCHAR(255) NULL AFTER specialty_certificate'); + await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1'); + metadataRows[0].schema_version = 18; + } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); @@ -349,7 +359,7 @@ export function createMysqlAdapter(context) { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 17, ?, ?, ?) + VALUES (1, 18, ?, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 01bccfc..2ac2e6b 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -47,7 +47,7 @@ export const sqliteSchema = ` username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), + role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')), admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, @@ -90,6 +90,9 @@ export const sqliteSchema = ` postal_code TEXT, guardian_name TEXT, guardian_phone TEXT, + specialty_types TEXT NOT NULL DEFAULT '[]', + specialty_certificate TEXT, + policy_eligibility TEXT, profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, @@ -426,6 +429,18 @@ export const sqliteSchema = ` created_at TEXT NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS admission_records ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification')), + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + school_id TEXT REFERENCES schools(id) ON DELETE CASCADE, + status TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at); CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end); @@ -446,6 +461,7 @@ export const sqliteSchema = ` CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status); CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert BEFORE INSERT ON results @@ -536,7 +552,7 @@ export const mysqlSchema = [ username VARCHAR(100) NOT NULL, candidate_number VARCHAR(120) NULL, password_hash VARCHAR(255) NOT NULL, - role ENUM('admin', 'candidate') NOT NULL, + role ENUM('admin', 'candidate', 'admission_school') NOT NULL, admin_level ENUM('super', 'school', 'class') NULL, school_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL, @@ -586,6 +602,9 @@ export const mysqlSchema = [ postal_code VARCHAR(20) NULL, guardian_name VARCHAR(100) NULL, guardian_phone VARCHAR(60) NULL, + specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()), + specialty_certificate VARCHAR(255) NULL, + policy_eligibility VARCHAR(255) NULL, profile_completed BOOLEAN NOT NULL DEFAULT FALSE, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, @@ -987,6 +1006,22 @@ export const mysqlSchema = [ CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS admission_records ( + id VARCHAR(64) NOT NULL, + kind ENUM('setting', 'plan', 'preference', 'placement', 'notification') NOT NULL, + exam_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NULL, + school_id VARCHAR(64) NULL, + status VARCHAR(40) NOT NULL, + payload_json JSON NOT NULL, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_admission_records_lookup (kind, exam_id, school_id, user_id, status), + CONSTRAINT fk_admission_record_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + CONSTRAINT fk_admission_record_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_admission_record_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS audit_logs ( id VARCHAR(64) NOT NULL, actor_id VARCHAR(64) NULL, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index 8916174..98f6331 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -32,6 +32,8 @@ export function createSqliteAdapter(context) { ensureColumns('users', [ ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], ['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'], + ['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'], + ['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'], ['archived_at', 'TEXT'], ['archived_by', 'TEXT'] ]); ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); @@ -39,7 +41,8 @@ export function createSqliteAdapter(context) { ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'], ['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'], ['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'], - ['district_code', 'TEXT'], ['district_name', 'TEXT'] + ['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"], + ['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT'] ]); ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); ensureColumns('exams', [ @@ -68,6 +71,32 @@ export function createSqliteAdapter(context) { ensureColumns('admit_card_subjects', [ ['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"] ]); + if (tableExists('users')) { + const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || ''; + if (!usersSql.includes('admission_school')) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE users_v18 ( + id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')), + admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), + totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT, + totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT, + archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL + ) STRICT; + INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, + active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'), + totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users; + DROP TABLE users; + ALTER TABLE users_v18 RENAME TO users; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } if (tableExists('workflow_definitions')) { const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; if (!definitionSql.includes('candidate_account_batch')) { @@ -246,13 +275,10 @@ export function createSqliteAdapter(context) { connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run(); } if (existingSystem && Number(existingSystem.schema_version || 1) < 17) { - connection.exec(` - ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)); - ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT; - ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]'; - ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER; - UPDATE schema_metadata SET schema_version = 17 WHERE id = 1; - `); + connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 18) { + connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run(); } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); @@ -414,7 +440,7 @@ export function createSqliteAdapter(context) { try { connection.prepare(` INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 17, ?, ?, ?) + VALUES (1, 18, ?, ?, ?) `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 3ca37f6..a40e26f 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,5 +1,6 @@ import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs'; import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs'; +import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs'; export function createAdminRoutes(context) { const { @@ -108,6 +109,16 @@ export function createAdminRoutes(context) { return ''; } + function normalizeAdmissionCategories(input) { + return (Array.isArray(input) ? input : []).map((item, index) => ({ + code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80), + quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyType: cleanText(item.specialtyType, 80), + indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({ + sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0))) + })).filter(item => item.sourceSchoolId && item.quota > 0) + })).filter(item => item.code && item.name && item.quota > 0); + } + async function handleAdmin(request, response, pathname) { if (!pathname.startsWith('/api/admin/')) return false; const user = await requireUser(request, response, 'admin'); @@ -128,6 +139,122 @@ export function createAdminRoutes(context) { classes: db.classes }); } + + if (pathname === '/api/admin/admissions' && request.method === 'GET') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据'); + const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] })); + const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) })); + const placements = admissionRecords(db, 'placement').map(placement => { + const account = db.users.find(item => item.id === placement.userId) || {}; + const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {}; + return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [] }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' }; + }); + const preferences = admissionRecords(db, 'preference').map(preference => { + const account = db.users.find(item => item.id === preference.userId) || {}; + const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {}; + return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) }; + }); + const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser); + return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), exams: db.exams.filter(item => !item.archivedAt) }); + } + if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号'); + const body = await readJson(request); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const username = cleanText(body.username, 80); + const password = String(body.password || ''); + if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码'); + if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '登录账号已存在'); + const account = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admission_school', schoolId: school.id, displayName: cleanText(body.displayName, 80) || `${school.name}招生办`, active: true, createdAt: nowIso() }; + await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`)); + return sendJson(response, 201, { ok: true, account: safeUser(account) }); + } + const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/); + if (settingMatch && request.method === 'PUT') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报'); + const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt); + if (!exam) return sendError(response, 404, '考试不存在或已经归档'); + const body = await readJson(request); + const status = admissionPhases.has(body.status) ? body.status : 'draft'; + const now = nowIso(); + const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now }; + setting.status = status; + setting.updatedAt = now; + setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' }; + if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间'); + await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`)); + return sendJson(response, 200, { ok: true, setting }); + } + if (pathname === '/api/admin/admission-plans' && request.method === 'POST') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划'); + const body = await readJson(request); + const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const categories = normalizeAdmissionCategories(body.categories); + if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划'); + if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数'); + if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID'); + const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id); + const now = nowIso(); + const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now }; + Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } }); + await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`)); + return sendJson(response, existing ? 200 : 201, { ok: true, plan }); + } + const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/); + if (planReviewMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核招生计划'); + const plan = admissionRecords(db, 'plan').find(item => item.id === planReviewMatch[1]); + if (!plan) return sendError(response, 404, '招生计划不存在'); + const body = await readJson(request); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); + plan.status = body.status; + plan.updatedAt = nowIso(); + plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt }; + await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id)); + return sendJson(response, 200, { ok: true, plan }); + } + const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/); + if (actionMatch && request.method === 'POST') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以执行投档与录取操作'); + const setting = admissionSetting(db, actionMatch[1]); + if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开启志愿填报'); + const action = actionMatch[2]; + if (action === 'match') { + if (!['closed', 'supplementary'].includes(setting.status)) return sendError(response, 409, '请先结束当前填报阶段再投档'); + const placements = buildVolunteerPlacements(db, setting, { uid, nowIso }); + setting.status = 'school_review'; setting.updatedAt = nowIso(); setting.payload.progress = `第 ${setting.payload.round || 1} 轮投档完成,${placements.length} 人已发送招生学校审核`; + await database.saveAdmissionRecords([setting, ...placements], logAction(db, user, '执行分数优先志愿投档', `${setting.examId} · ${placements.length} 人`)); + return sendJson(response, 200, { ok: true, setting, placementCount: placements.length }); + } + if (action === 'finalize') { + const placements = admissionRecords(db, 'placement', setting.examId); + if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理'); + const now = nowIso(); + const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now })); + const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } })); + setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果已经通知并自动公示'; setting.payload.completedAt = now; + await database.saveAdmissionRecords([setting, ...admitted, ...notifications], logAction(db, user, '结束录取并发布结果', `${setting.examId} · ${admitted.length} 人`)); + return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows({ ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }, setting.examId) }); + } + const body = await readJson(request); + const now = nowIso(); + setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' }; + await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round} 轮`)); + return sendJson(response, 200, { ok: true, setting }); + } + const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/); + if (withdrawalMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档'); + const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending'); + if (!placement) return sendError(response, 404, '待审核退档申请不存在'); + const body = await readJson(request); + placement.status = body.approved === true ? 'withdrawn' : 'admitted'; + placement.updatedAt = nowIso(); + placement.payload.withdrawalReviewNote = cleanText(body.reviewNote, 500); + await database.saveAdmissionRecord(placement, logAction(db, user, body.approved === true ? '批准退档' : '驳回退档', placement.id)); + return sendJson(response, 200, { ok: true, placement }); + } const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/); if (excelMatch && request.method === 'GET') { diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs new file mode 100644 index 0000000..5e0ad78 --- /dev/null +++ b/src/routes/admission.routes.mjs @@ -0,0 +1,85 @@ +import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs'; + +function normalizeCategories(input, cleanText) { + const source = Array.isArray(input) ? input : []; + return source.map((item, index) => ({ + code: cleanText(item.code || `category_${index + 1}`, 40), + name: cleanText(item.name, 80), + quota: Math.max(0, Math.trunc(Number(item.quota || 0))), + specialtyType: cleanText(item.specialtyType, 80), + indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({ + sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0))) + })).filter(item => item.sourceSchoolId && item.quota > 0) + })).filter(item => item.code && item.name && item.quota > 0); +} + +export function createAdmissionRoutes(context) { + const { database, readDb, sendJson, sendError, readJson, requireUser, cleanText, maskId, uid, nowIso, logAction } = context; + + async function handleAdmission(request, response, pathname) { + if (!pathname.startsWith('/api/admission/')) return false; + const user = await requireUser(request, response, 'admission_school'); + if (!user) return true; + const db = await readDb(); + const school = db.schools.find(item => item.id === user.schoolId && item.active); + if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校'); + + if (request.method === 'GET' && pathname === '/api/admission/context') { + return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) }); + } + if (request.method === 'GET' && pathname === '/api/admission/plans') { + const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan) })); + return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt) }); + } + if (request.method === 'POST' && pathname === '/api/admission/plans') { + const body = await readJson(request); + const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt); + if (!exam) return sendError(response, 404, '考试不存在或已经归档'); + const categories = normalizeCategories(body.categories, cleanText); + if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数'); + if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数'); + if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID'); + const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id); + if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整'); + const now = nowIso(); + const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now }; + Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } }); + await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`)); + return sendJson(response, existing ? 200 : 201, { ok: true, plan }); + } + if (request.method === 'GET' && pathname === '/api/admission/placements') { + const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => { + const account = db.users.find(entry => entry.id === item.userId) || {}; + const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {}; + const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId); + const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => { + const exam = db.exams.find(entry => entry.id === item.examId); + return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }; + }); + return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [], specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, results }; + }); + return sendJson(response, 200, { ok: true, school, placements }); + } + const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/); + if (request.method === 'PATCH' && placementMatch) { + const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id); + if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在'); + const body = await readJson(request); + const decision = cleanText(body.decision, 30); + const note = cleanText(body.note, 500); + if (decision === 'accept') placement.status = 'admitted'; + else if (decision === 'withdraw') { + if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由'); + placement.status = 'withdrawal_pending'; + placement.payload.withdrawalReason = note; + } else return sendError(response, 400, '请选择接收或申请退档'); + placement.payload.schoolDecisionNote = note; + placement.updatedAt = nowIso(); + await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`)); + return sendJson(response, 200, { ok: true, placement }); + } + return sendError(response, 404, '招生学校功能接口不存在'); + } + + return handleAdmission; +} diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 895c9e1..5ad02d8 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -1,4 +1,5 @@ import { noticeForClient } from '../security/notice-content.mjs'; +import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs'; export function createCandidateRoutes(context) { const { @@ -81,8 +82,9 @@ export function createCandidateRoutes(context) { } if (request.method === 'PUT' && pathname === '/api/candidate/profile') { const body = await readJson(request); - const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone']; + const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone', 'specialtyCertificate', 'policyEligibility']; for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80); + profile.specialtyTypes = [...new Set(String(body.specialtyTypes || '').split(/[,,]/).map(item => cleanText(item, 40)).filter(Boolean))].slice(0, 10); const region = resolveRegion(body); if (!region) return sendError(response, 400, '请选择有效的省、市和区县'); Object.assign(profile, region); @@ -151,6 +153,44 @@ export function createCandidateRoutes(context) { }, { ttlSeconds: resultsCacheTtlSeconds }); return sendJson(response, 200, payload); } + if (request.method === 'GET' && pathname === '/api/candidate/admissions') { + const settings = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => { + const exam = db.exams.find(item => item.id === setting.examId); + const round = Number(setting.payload?.round || 1); + const preference = activePreference(db, setting.examId, user.id, round); + const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn'); + const plans = approvedPlans(db, setting.examId).map(plan => ({ + id: plan.id, schoolId: plan.schoolId, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', + categories: remainingPlanQuota(db, plan) + })); + return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id) }; + }).filter(item => item.exam); + const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id); + return sendJson(response, 200, { ok: true, admissions: settings, notifications }); + } + const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/); + if (request.method === 'PUT' && preferenceMatch) { + const setting = admissionSetting(db, preferenceMatch[1]); + if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报'); + if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段'); + const now = Date.now(); + if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始'); + if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止'); + if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿'); + const body = await readJson(request); + const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5)); + const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40) })); + if (!choices.length) return sendError(response, 400, '请至少选择一个志愿'); + if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同一学校和招生类别不能重复填报'); + const plans = approvedPlans(db, setting.examId); + if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode)))) return sendError(response, 400, '志愿中包含未审核通过的学校或招生类别'); + const round = Number(setting.payload.round || 1); + const nowValue = nowIso(); + const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue }; + Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue } }); + await database.saveAdmissionRecord(preference); + return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' }); + } const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/); if (request.method === 'POST' && scoreAppealMatch) { const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published); diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index 72062cb..d22e9b3 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -1,4 +1,5 @@ import { noticeForClient } from '../security/notice-content.mjs'; +import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs'; export function createPublicRoutes(context) { const { @@ -57,7 +58,8 @@ export function createPublicRoutes(context) { const db = await readDb(); const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient); const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); - return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } }; + const admissionAnnouncements = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', completedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })); + return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, admissionAnnouncements, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } }; }); return sendJson(response, 200, payload); } diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs new file mode 100644 index 0000000..8cf2580 --- /dev/null +++ b/src/services/volunteer-admission.mjs @@ -0,0 +1,127 @@ +export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'supplementary', 'completed']); + +export function admissionRecords(db, kind, examId = null) { + return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId)); +} + +export function admissionSetting(db, examId) { + return admissionRecords(db, 'setting', examId)[0] || null; +} + +export function candidateTotalScore(db, examId, userId) { + const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved'); + if (!registration) return null; + const results = db.results.filter(item => item.registrationId === registration.id && item.published); + if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null; + return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)); +} + +export function activePreference(db, examId, userId, round) { + return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null; +} + +export function approvedPlans(db, examId) { + return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved'); +} + +export function planSummary(plan) { + const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : []; + return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) }; +} + +export function publicAdmissionRows(db, examId) { + return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => { + const user = db.users.find(entry => entry.id === item.userId) || {}; + const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {}; + const school = db.schools.find(entry => entry.id === item.schoolId) || {}; + return { + registrationNumber: user.candidateNumber || '', + name: profile.name || user.displayName || '', + totalScore: Number(item.payload?.totalScore || 0), + admittedSchool: school.name || '', + categoryName: item.payload?.categoryName || '', + idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '', + phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : '' + }; + }).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber)); +} + +function categoryKey(schoolId, code) { + return `${schoolId}|${code}`; +} + +export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { + const examId = setting.examId; + const round = Number(setting.payload?.round || 1); + const plans = approvedPlans(db, examId); + const categories = new Map(); + for (const plan of plans) for (const category of plan.payload?.categories || []) { + categories.set(categoryKey(plan.schoolId, category.code), { plan, category }); + } + + const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn'); + const occupied = new Map(); + const occupiedIndicators = new Map(); + const occupiedGeneral = new Map(); + for (const placement of existing) { + const key = categoryKey(placement.schoolId, placement.payload?.categoryCode); + occupied.set(key, (occupied.get(key) || 0) + 1); + if (placement.payload?.quotaBucket?.startsWith('indicator:')) { + const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`; + occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1); + } else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1); + } + + const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted'); + const candidates = preferences.map(preference => { + const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {}; + const account = db.users.find(item => item.id === preference.userId) || {}; + return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) }; + }).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status))) + .sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || ''))); + + const created = []; + for (const candidate of candidates) { + const specialtyTypes = Array.isArray(candidate.profile.specialtyTypes) ? candidate.profile.specialtyTypes : []; + for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) { + const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode)); + if (!target) continue; + const { category } = target; + if (category.specialtyType && !specialtyTypes.includes(category.specialtyType)) continue; + const key = categoryKey(choice.schoolId, choice.categoryCode); + if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue; + const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId); + let quotaBucket = null; + if (allocation) { + const indicatorKey = `${key}|${candidate.profile.schoolId}`; + if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) { + quotaBucket = `indicator:${candidate.profile.schoolId}`; + occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1); + } + } + if (!quotaBucket) { + const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0)); + if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue; + quotaBucket = 'general'; + occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1); + } + occupied.set(key, (occupied.get(key) || 0) + 1); + created.push({ + id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId, + status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: { + round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1, + totalScore: candidate.score, quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: '' + } + }); + break; + } + } + return created; +} + +export function remainingPlanQuota(db, plan) { + return (plan.payload?.categories || []).map(category => { + const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn').length; + return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) }; + }); +} diff --git a/styles.css b/styles.css index 8976ed8..e1783a7 100644 --- a/styles.css +++ b/styles.css @@ -581,3 +581,15 @@ button:disabled { cursor: not-allowed; opacity: .5; } @media (max-width: 620px) { .scope-banner { align-items:flex-start; }.candidate-flow-note { grid-template-columns:1fr; }.segment-option { grid-template-columns:18px 52px 1fr; }.segment-value,.segment-width { grid-column:2/-1; }.workflow-step-row { grid-template-columns:28px 1fr 26px; }.workflow-step-row > i { width:28px;height:28px; }.workflow-step-row select { grid-column:2/3; }.workflow-owner { grid-template-columns:1fr; }.center-card dl { grid-template-columns:1fr; }.number-rule-layout { display:block; }.rule-preview { margin-top:14px; }.center-summary,.center-metrics,.center-profile,.batch-number-panel form,.room-editor-grid,.flow-center-snapshot dl { grid-template-columns:1fr; }.center-dossier > header,.center-dossier > footer,.center-form-section > header,.approval-callout { align-items:flex-start; flex-direction:column; }.center-dossier > header > div:last-child { flex-wrap:wrap; }.room-editor-grid .room-notes { grid-column:auto; }.onboarding-page { display:block; }.onboarding-identity { position:relative; min-height:auto; padding:25px 20px; }.onboarding-identity > span { margin-top:35px; }.onboarding-identity > p { display:none; }.onboarding-identity > button { position:absolute; top:26px; right:20px; }.onboarding-steps { margin-top:30px; }.onboarding-steps > div { min-height:58px; }.onboarding-work { padding:30px 14px 45px; }.profile-form .wide-field { grid-column:auto; }.registration-policy { align-items:flex-start; flex-direction:column; }.registration-policy form { width:100%; justify-content:space-between; }.account-number-principle { grid-template-columns:1fr; }.account-number-principle span { grid-row:auto; writing-mode:horizontal-tb; } } + +/* 中考志愿与招生录取:唯一强调元素是贯穿全流程的进度轨道。 */ +.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); } +.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; } +.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); } +.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; } +.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; } +.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; } +.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; } +.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; } +@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } } +@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } } diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs new file mode 100644 index 0000000..b97db23 --- /dev/null +++ b/tests/admission.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs'; + +const now = '2026-07-21T08:00:00.000Z'; +let sequence = 0; +const db = { + users: [ + { id: 'u-high', candidateNumber: '20260001', displayName: '高分考生' }, + { id: 'u-low', candidateNumber: '20260002', displayName: '次高考生' }, + { id: 'u-sport', candidateNumber: '20260003', displayName: '特长考生' } + ], + candidateProfiles: [ + { userId: 'u-high', name: '高分考生', schoolId: 'source-a', idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] }, + { userId: 'u-low', name: '次高考生', schoolId: 'source-b', idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] }, + { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyTypes: ['田径'] } + ], + schools: [ + { id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' }, + { id: 'target-a', name: '第一中学' }, { id: 'target-b', name: '第二中学' } + ], + registrations: [ + { id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] }, + { id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] }, + { id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'] } + ], + results: [ + { registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true }, + { registrationId: 'r-low', subjectId: 'cn', score: 118, published: true }, { registrationId: 'r-low', subjectId: 'math', score: 126, published: true }, + { registrationId: 'r-sport', subjectId: 'cn', score: 105, published: true }, { registrationId: 'r-sport', subjectId: 'math', score: 110, published: true } + ], + admissionRecords: [ + { id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } }, + { id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [ + { code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }, + { code: 'sport', name: '田径特长生', quota: 1, specialtyType: '田径', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] } + ] } }, + { id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } }, + { id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } }, + { id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport' }] } } + ] +}; + +const setting = { examId: 'exam', payload: { round: 1 } }; +const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now }); +assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和'); +assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档'); +assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿'); +assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿'); +assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额'); + +db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' }))); +const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b')); +assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计'); +assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计'); +const publicRows = publicAdmissionRows(db, 'exam'); +assert.equal(publicRows[0].registrationNumber, '20260001', '公示必须公开报名号'); +assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名'); +assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩'); +assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校'); +assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏'); + +console.log('志愿投档、指标名额与脱敏公示测试通过'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 358772b..2c7d944 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -221,7 +221,7 @@ try { inspector.close(); assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); - assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构'); + assert.equal(schemaVersion, 18, '志愿填报与招生录取应使用 v18 数据结构'); assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表'); assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏'); assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');