From ae528b20e982e1054b82089d31c3f1a03ebd22f5 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 12:49:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=BF=97=E6=84=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++++ app.js | 59 +++++++++++++ database.mjs | 72 ++++++++++++++- package.json | 2 +- server.mjs | 5 ++ src/client/admin-views.mjs | 11 ++- src/client/admission-views.mjs | 34 +++++++ src/client/candidate-views.mjs | 32 +++++-- src/client/public-views.mjs | 5 +- src/client/state.mjs | 2 +- src/client/ui.mjs | 2 + src/data/base.mjs | 4 +- src/data/seed.mjs | 1 + src/database/mysql-adapter.mjs | 12 ++- src/database/schema.mjs | 39 +++++++- src/database/sqlite-adapter.mjs | 44 ++++++++-- src/routes/admin.routes.mjs | 127 +++++++++++++++++++++++++++ src/routes/admission.routes.mjs | 85 ++++++++++++++++++ src/routes/candidate.routes.mjs | 42 ++++++++- src/routes/public.routes.mjs | 4 +- src/services/volunteer-admission.mjs | 127 +++++++++++++++++++++++++++ styles.css | 12 +++ tests/admission.test.mjs | 62 +++++++++++++ tests/system.test.mjs | 2 +- 24 files changed, 770 insertions(+), 30 deletions(-) create mode 100644 src/client/admission-views.mjs create mode 100644 src/routes/admission.routes.mjs create mode 100644 src/services/volunteer-admission.mjs create mode 100644 tests/admission.test.mjs 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 ? `
` : ''}

代上传招生计划

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

招生计划审核

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

考生志愿只读台账

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

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

中考招生录取控制台

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

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

考试志愿设置

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

${selected ? `
` : ''}

代上传招生计划

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

招生计划审核

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

投档与退档监督

超级管理员可见,任何管理员均不能修改考生志愿
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.payload.totalScore)} 分 · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; } diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index a40e26f..84cdfce 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -175,9 +175,11 @@ export function createAdminRoutes(context) { const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt); if (!exam) return sendError(response, 404, '考试不存在或已经归档'); const body = await readJson(request); - const status = admissionPhases.has(body.status) ? body.status : 'draft'; const now = nowIso(); const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now }; + const requestedStatus = admissionPhases.has(body.status) ? body.status : 'draft'; + const manualPhases = ['draft', 'filling', 'closed']; + const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft'); setting.status = status; setting.updatedAt = now; setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' }; @@ -195,6 +197,7 @@ export function createAdminRoutes(context) { if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数'); if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID'); const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id); + if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划'); const now = nowIso(); const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now }; Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } }); @@ -239,6 +242,7 @@ export function createAdminRoutes(context) { } const body = await readJson(request); const now = nowIso(); + if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录'); setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' }; await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round} 轮`)); return sendJson(response, 200, { ok: true, setting }); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 2c7d944..6a1a4f9 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { pbkdf2Sync, randomBytes } from 'node:crypto'; import { readFile, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { createServer as createNetServer } from 'node:net'; import assert from 'node:assert/strict'; import ExcelJS from 'exceljs'; import { createDatabase, relationalTables } from '../database.mjs'; @@ -13,7 +14,14 @@ import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs'; const root = resolve(process.cwd()); assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果'); -const port = 4182; +const port = await new Promise((resolvePort, rejectPort) => { + const probe = createNetServer(); + probe.once('error', rejectPort); + probe.listen(0, '127.0.0.1', () => { + const selectedPort = probe.address().port; + probe.close(error => error ? rejectPort(error) : resolvePort(selectedPort)); + }); +}); const baseUrl = `http://127.0.0.1:${port}`; const testDb = resolve(root, 'data', 'test-db.sqlite'); const mysqlRuleSchemas = mysqlSchema.filter(statement => /CREATE TABLE IF NOT EXISTS (?:admission_number_rules|number_rules)\b/.test(statement)); @@ -961,8 +969,11 @@ try { console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); } finally { - server.kill('SIGTERM'); - await new Promise(resolveWait => server.once('exit', resolveWait)); + if (server.exitCode == null && server.signalCode == null) { + const serverExit = new Promise(resolveWait => server.once('exit', resolveWait)); + server.kill('SIGTERM'); + await serverExit; + } await rm(testDb, { force: true }); await rm(`${testDb}-shm`, { force: true }); await rm(`${testDb}-wal`, { force: true }); From 5e0bee60e27a0a804ed65c921f4dd040556b26aa Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 14:04:19 +0800 Subject: [PATCH 3/5] Add structured admission plans and school role management --- README.md | 8 +-- app.js | 80 ++++++++++++++++++++++++---- database.mjs | 52 ++++++++++++------ excel.mjs | 15 ++++++ server.mjs | 9 ++-- src/client/admin-views.mjs | 31 +++++++++-- src/client/admission-plan-editor.mjs | 16 ++++++ src/client/admission-views.mjs | 7 ++- src/client/candidate-views.mjs | 18 +++++-- src/data/base.mjs | 2 +- src/data/specialty-types.mjs | 76 ++++++++++++++++++++++++++ src/database/mysql-adapter.mjs | 16 +++++- src/database/schema.mjs | 10 ++++ src/database/sqlite-adapter.mjs | 9 +++- src/routes/admin.routes.mjs | 49 +++++++++++++---- src/routes/admission.routes.mjs | 45 +++++++++++++--- src/routes/auth.routes.mjs | 2 +- src/routes/candidate.routes.mjs | 26 +++++---- src/routes/public.routes.mjs | 2 +- src/services/volunteer-admission.mjs | 7 +-- styles.css | 29 ++++++++++ tests/admission.test.mjs | 10 ++-- tests/system.test.mjs | 46 ++++++++++++++-- 23 files changed, 483 insertions(+), 82 deletions(-) create mode 100644 src/client/admission-plan-editor.mjs create mode 100644 src/data/specialty-types.mjs diff --git a/README.md b/README.md index 991c93f..81b7a59 100644 --- a/README.md +++ b/README.md @@ -261,15 +261,17 @@ npm run seed-test-data:mysql -- --force 系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下: 1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。 -2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。 +2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。 3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。 4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。 5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。 -公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。 +公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。 -数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 +学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。 + +数据结构版本为 v19,`admission_records` 关系表支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 ## 手动测试数据账号 diff --git a/app.js b/app.js index 3c5dadf..da8fb01 100644 --- a/app.js +++ b/app.js @@ -2,10 +2,12 @@ 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 { admissionCategoryEditor, indicatorAllocationEditor } from './src/client/admission-plan-editor.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'; import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs'; +import { specialtyCatalog } from './src/data/specialty-types.mjs'; const app = document.querySelector('#app'); const modalRoot = document.querySelector('#modalRoot'); @@ -184,6 +186,12 @@ 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 === 'download-admitted-candidates') { + const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value; + if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载'); + window.location.href = `/api/admission/placements/export?examId=${encodeURIComponent(examId)}`; + return; + } if (action === 'admission-plan-review') { const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null; if (reviewNote == null) return; @@ -213,6 +221,26 @@ document.addEventListener('click', async event => { await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } }); toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute(); } + if (action === 'add-admission-category') { + const sources = state.pageData?.sourceSchools || []; + target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources)); + return; + } + if (action === 'remove-admission-category') { + const list = target.closest('[data-admission-categories]'); + if (list?.children.length <= 1) return toast('至少保留一个招生类别'); + target.closest('.admission-category-editor')?.remove(); + return; + } + if (action === 'add-indicator-allocation') { + const sources = state.pageData?.sourceSchools || []; + target.closest('.indicator-allocation-editor')?.querySelector('[data-indicator-allocations]')?.insertAdjacentHTML('beforeend', indicatorAllocationEditor(h, sources)); + return; + } + if (action === 'remove-indicator-allocation') { + target.closest('.indicator-allocation-row')?.remove(); + return; + } 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; @@ -458,6 +486,35 @@ document.addEventListener('change', event => { classSelect.innerHTML = `${classes.filter(item => item.schoolId === event.target.value).map(item => ``).join('')}`; } } + if (event.target.matches('[data-action="specialty-category"]')) { + const typeSelect = event.target.closest('.admission-category-editor')?.querySelector('[data-specialty-type]') || event.target.closest('form')?.querySelector('[data-specialty-type]'); + const category = specialtyCatalog.find(item => item.code === event.target.value); + if (typeSelect) { + typeSelect.disabled = !category; + typeSelect.innerHTML = `${(category?.types || []).map(item => ``).join('')}`; + } + } + if (event.target.matches('[data-action="plan-category-kind"]')) { + const editor = event.target.closest('.admission-category-editor'); + const specialtyFields = editor?.querySelector('[data-plan-specialty]'); + const enabled = event.target.value === 'specialty'; + specialtyFields?.classList.toggle('hidden', !enabled); + specialtyFields?.querySelectorAll('select').forEach(select => { select.disabled = !enabled || (select.hasAttribute('data-specialty-type') && !specialtyFields.querySelector('[name="categorySpecialtyCategory"]')?.value); }); + } + if (event.target.matches('[name="categoryName"]')) { + const title = event.target.closest('.admission-category-editor')?.querySelector('header strong'); + if (title) title.textContent = event.target.value.trim() || '新类别'; + } + if (event.target.matches('[data-action="preference-school"]')) { + const row = event.target.closest('.preference-choice-row'); + const categorySelect = row?.querySelector('[name="choiceCategory"]'); + const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId); + const plan = admission?.plans?.find(item => item.schoolId === event.target.value); + if (categorySelect) { + categorySelect.disabled = !plan; + categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.remaining > 0).map(item => ``).join('')}`; + } + } if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target); if (event.target.matches('[data-action="admin-level"]')) { const form = event.target.closest('form'); @@ -560,9 +617,7 @@ document.addEventListener('submit', async event => { 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 }; - }); + const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value })).filter(item => item.schoolId && item.categoryCode); await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute(); } else if (kind === 'exam-registration') { @@ -595,12 +650,13 @@ document.addEventListener('submit', async event => { 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 }; + body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => { + const specialty = editor.querySelector('[name="categoryKind"]').value === 'specialty'; + const indicatorAllocations = [...editor.querySelectorAll('.indicator-allocation-row')].map(row => ({ sourceSchoolId: row.querySelector('[name="indicatorSchool"]').value, quota: Number(row.querySelector('[name="indicatorQuota"]').value || 0) })).filter(item => item.sourceSchoolId && item.quota > 0); + return { code: `category_${index + 1}`, name: editor.querySelector('[name="categoryName"]').value.trim(), quota: Number(editor.querySelector('[name="categoryQuota"]').value || 0), isSpecialty: specialty, specialtyCategory: specialty ? editor.querySelector('[name="categorySpecialtyCategory"]').value : '', specialtyType: specialty ? editor.querySelector('[name="categorySpecialtyType"]').value : '', indicatorAllocations }; }).filter(item => item.name && item.quota > 0); - if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划'); + if (!body.categories.length) throw new Error('请至少添加一个有效招生类别'); + if (body.categories.some(item => item.isSpecialty && (!item.specialtyCategory || !item.specialtyType))) 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') { @@ -608,7 +664,7 @@ document.addEventListener('submit', async event => { 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; + const body = formObject(form); body.active = form.active.checked; body.isSourceSchool = form.isSourceSchool.checked; body.isAdmissionSchool = form.isAdmissionSchool.checked; await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body }); closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute(); } else if (kind === 'school-class') { @@ -713,6 +769,10 @@ document.addEventListener('submit', async event => { const body = formObject(form); body.published = form.published.checked; await api('/api/admin/results', { method: 'POST', body }); toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute(); + } else if (kind === 'feature-score-entry') { + const body = formObject(form); body.featureScore = Number(body.featureScore || 0); + await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body }); + toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute(); } else if (kind === 'result-import-commit') { const rows = state.resultImportPreview?.rows || []; if (!rows.length) throw new Error('没有可提交的成绩预览'); @@ -730,7 +790,7 @@ function openAdminForm() { } function openSchoolForm(school = null) { - setModal(``); + setModal(``); } function openSchoolClassForm(schoolClass = null) { diff --git a/database.mjs b/database.mjs index a543650..134d600 100644 --- a/database.mjs +++ b/database.mjs @@ -60,7 +60,7 @@ export function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 18, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 19, 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() ); @@ -71,8 +71,9 @@ export function buildSeedOperations(state) { for (const school of state.schools) { add( - 'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', - school.id, school.name, school.code, nullable(school.address), school.active === false ? 0 : 1 + 'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)', + school.id, school.name, school.code, nullable(school.address), school.isSourceSchool === false ? 0 : 1, + school.isAdmissionSchool === false ? 0 : 1, school.active === false ? 0 : 1 ); } @@ -101,8 +102,9 @@ export function buildSeedOperations(state) { id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, province_code, province_name, city_code, city_name, district_code, district_name, address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, - guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + guardian_phone, specialty_category, specialty_type, specialty_types, specialty_certificate, policy_eligibility, + profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone, nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId), nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName), @@ -110,6 +112,8 @@ export function buildSeedOperations(state) { nullable(profile.address), nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate), nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone), + nullable(profile.specialtyCategory), nullable(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []), + nullable(profile.specialtyCertificate), nullable(profile.policyEligibility), profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote), nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt ); @@ -151,12 +155,12 @@ export function buildSeedOperations(state) { add( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, - registration_number, number_rule_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + registration_number, number_rule_id, feature_score + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote), - nullable(registration.registrationNumber), nullable(registration.numberRuleId) + nullable(registration.registrationNumber), nullable(registration.numberRuleId), Number(registration.featureScore || 0) ); for (const subjectId of registration.subjectIds) { add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); @@ -466,6 +470,8 @@ function stateFromRows(rows) { name: row.name, code: row.code, address: row.address || '', + isSourceSchool: row.is_source_school == null ? true : Boolean(row.is_source_school), + isAdmissionSchool: row.is_admission_school == null ? true : Boolean(row.is_admission_school), active: Boolean(row.active) })), classes: rows.classes.map(row => ({ @@ -522,6 +528,8 @@ function stateFromRows(rows) { postalCode: row.postal_code || '', guardianName: row.guardian_name || '', guardianPhone: row.guardian_phone || '', + specialtyCategory: row.specialty_category || '', + specialtyType: row.specialty_type || '', specialtyTypes: parseJson(row.specialty_types, []), specialtyCertificate: row.specialty_certificate || '', policyEligibility: row.policy_eligibility || '', @@ -578,6 +586,7 @@ function stateFromRows(rows) { reviewNote: row.review_note || '', registrationNumber: row.registration_number || '', numberRuleId: row.number_rule_id || null, + featureScore: Number(row.feature_score || 0), admitCard: admitCards.get(row.id) || null })), results: rows.results.map(row => ({ @@ -956,7 +965,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 = ?, + specialty_category = ?, specialty_type = ?, 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), @@ -965,7 +974,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), JSON.stringify(profile.specialtyTypes || []), + optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), optional(profile.specialtyCategory), + optional(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []), optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), @@ -1011,12 +1021,12 @@ function createRepository({ client, location, read, transaction, close }) { const operations = [operation( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, - registration_number, number_rule_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + registration_number, number_rule_id, feature_score + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote), - optional(registration.registrationNumber), optional(registration.numberRuleId) + optional(registration.registrationNumber), optional(registration.numberRuleId), Number(registration.featureScore || 0) )]; for (const subjectId of registration.subjectIds) { operations.push(operation( @@ -1230,6 +1240,12 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, + async updateFeatureScore(registration, log) { + await transaction([ + operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id), + auditOperation(log) + ]); + }, async saveAdmissionRecord(record, log = null) { const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation( `INSERT INTO admission_records ( @@ -1259,12 +1275,14 @@ function createRepository({ client, location, read, transaction, close }) { async saveSchool(school, isNew, log) { const change = isNew ? operation( - 'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', - school.id, school.name, school.code, optional(school.address), school.active ? 1 : 0 + 'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)', + school.id, school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0, + school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0 ) : operation( - 'UPDATE schools SET name = ?, code = ?, address = ?, active = ? WHERE id = ?', - school.name, school.code, optional(school.address), school.active ? 1 : 0, school.id + 'UPDATE schools SET name = ?, code = ?, address = ?, is_source_school = ?, is_admission_school = ?, active = ? WHERE id = ?', + school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0, + school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0, school.id ); await transaction([change, auditOperation(log)]); }, diff --git a/excel.mjs b/excel.mjs index 0f2b193..6c9466c 100644 --- a/excel.mjs +++ b/excel.mjs @@ -108,6 +108,21 @@ const resourceSpecs = { ['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''], ['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, ''] ] + }, + admitted_candidates: { + title: '录取考生信息表', sheet: '录取考生', + columns: [ + ['candidateNumber', '报名号', 26, ''], ['name', '姓名', 14, ''], ['gender', '性别', 10, ''], + ['idNumber', '证件号码', 24, ''], ['phone', '手机号', 18, ''], ['email', '邮箱', 24, ''], + ['birthDate', '出生日期', 14, ''], ['ethnicity', '民族', 12, ''], ['nativePlace', '籍贯', 18, ''], + ['sourceSchoolCode', '生源学校代码', 16, ''], ['sourceSchool', '生源学校', 26, ''], ['className', '班级', 18, ''], + ['address', '家庭住址', 36, ''], ['guardianName', '监护人', 14, ''], ['guardianPhone', '监护人电话', 18, ''], + ['specialty', '特长生资格', 20, ''], ['specialtyCertificate', '特长证明编号', 20, ''], ['policyEligibility', '政策资格说明', 24, ''], + ['featureScore', '特征分', 12, ''], ['subjectScores', '各科成绩', 42, ''], ['totalScore', '考生总成绩', 14, ''], + ['admittedSchool', '录取学校', 26, ''], ['categoryName', '录取类别', 20, ''], ['preferenceOrder', '志愿序号', 12, ''] + ], + numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'], + numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' } } }; diff --git a/server.mjs b/server.mjs index 855bbc6..4c293fe 100644 --- a/server.mjs +++ b/server.mjs @@ -47,11 +47,13 @@ const staticFiles = new Set([ '/src/client/admin-views.mjs', '/src/client/candidate-views.mjs', '/src/client/admission-views.mjs', + '/src/client/admission-plan-editor.mjs', '/src/client/public-views.mjs', '/src/client/state.mjs', '/src/client/ui.mjs', '/src/client/region-select.mjs', - '/src/data/china-regions.mjs' + '/src/data/china-regions.mjs', + '/src/data/specialty-types.mjs' ]); const vendorStaticFiles = new Map([ ['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')], @@ -98,7 +100,7 @@ const initializeDatabase = () => createBaseDatabase({ }); const persistentDatabase = await createDatabase({ root, seed: initializeDatabase }); const cache = await createRedisCache(); -const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateExam', 'archiveExam']); +const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateExam', 'archiveExam']); const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => { const namespaces = ['public']; const instance = args[0]; @@ -423,6 +425,7 @@ function examResultSummary(db, registration) { complete, publishedSubjects: published.length, subjectCount: subjects.length, + featureScore: Number(registration.featureScore || 0), total, fullScore, scoreRatio: Number(scoreRatio.toFixed(2)), @@ -459,7 +462,7 @@ function logAction(db, user, action, detail) { const excelResourceNames = { classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账', - admit_cards: '准考证信息台账' + admit_cards: '准考证信息台账', admitted_candidates: '录取考生信息' }; function admissionRowsForRegistrations(db, registrations) { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 3bbcf21..44e378e 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -1,4 +1,6 @@ import { formatRegionAddress } from './region-select.mjs'; +import { admissionCategoriesEditor } from './admission-plan-editor.mjs'; +import { specialtyLabel } from '../data/specialty-types.mjs'; export const numberSegmentMeta = { year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], @@ -57,8 +59,8 @@ export function createAdminViews(context) { const content = { 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), admissions: () => adminAdmissions(data), security: () => accountSecurity(data) + schools: () => adminSchoolsV2(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), admissions: () => adminAdmissionsV2(data), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); if (page === 'admissions') mountPreferenceLedger(data); @@ -87,6 +89,14 @@ export function createAdminViews(context) { return `
学校总数${schools.length}
启用学校${activeCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${schools.map(item => ``).join('') || ''}
学校学校代码地址班级管理员考生考点状态操作
${h(item.name)}${h(item.id)}${h(item.code)}${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
还没有学校,请先创建学校档案。
`; } + function adminSchoolsV2(data) { + const schools = data.schools || []; + const sourceCount = schools.filter(item => item.active && item.isSourceSchool).length; + const admissionCount = schools.filter(item => item.active && item.isAdmissionSchool).length; + const roles = item => `${item.isSourceSchool ? '生源校' : ''}${item.isAdmissionSchool ? '招生校' : ''}`; + return `
学校总数${schools.length}
启用生源校${sourceCount}
启用招生校${admissionCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${schools.map(item => ``).join('') || ''}
学校 / 代码学校类型地址班级管理员考生考点状态操作
${h(item.name)}${h(item.code)}
${roles(item)}
${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
还没有学校,请先创建学校档案。
`; + } + function adminSchoolOrganization(data) { const classes = data.classes || []; const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); @@ -151,6 +161,19 @@ export function createAdminViews(context) { return `
ADMISSION COMMAND

中考招生录取控制台

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

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

考试志愿设置

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

${selected ? `
` : ''}

代上传招生计划

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

招生计划审核

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

投档与退档监督

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

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
` : ''}
`; + const accounts = ``; + const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; + const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
`; + const placements = `

投档与退档监督

志愿只读,管理员均无修改入口
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; + return `
ADMISSION COMMAND

中考招生录取控制台

学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
${settings}${accounts}
${planForm}${plans}${placements}`; + } + 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)}
`; } @@ -184,6 +207,8 @@ export function createAdminViews(context) { const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `

单科成绩录入

按考试和科目缩小候选范围,已有成绩会自动回填。

实时校验

选择科目后显示其独立及格规则。

` : ''; + const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); + const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
UNIFIED SPECIALTY TEST

特征分登记

每场考试、每名考生均有独立特征分;未参加统一测试时保持 0 分,与考试科目和文化课总分分开。

` : ''; const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${preview.rows.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${examAppeals.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
`; const examButton = exam => ``; @@ -197,7 +222,7 @@ export function createAdminViews(context) { const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; const ledger = `
${examResults.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
`; const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; - return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${ledger}${appealLedger}`; + return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${featureEntry}${ledger}${appealLedger}`; } function adminUsers(data) { diff --git a/src/client/admission-plan-editor.mjs b/src/client/admission-plan-editor.mjs new file mode 100644 index 0000000..3458d6b --- /dev/null +++ b/src/client/admission-plan-editor.mjs @@ -0,0 +1,16 @@ +import { specialtyCatalog } from '../data/specialty-types.mjs'; + +export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) { + return `
`; +} + +export function admissionCategoryEditor(h, sourceSchools = [], category = {}) { + const specialty = Boolean(category.specialtyCategory); + const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory); + return `
招生类别${h(category.name || '新类别')}
指标分配可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。
${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}
`; +} + +export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) { + const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }]; + return `
招生类别与计划逐项设置类别、资格范围和生源校指标。
${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}
`; +} diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs index 82a0adf..098dc33 100644 --- a/src/client/admission-views.mjs +++ b/src/client/admission-views.mjs @@ -24,11 +24,14 @@ export function createAdmissionViews(context) { } 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 || '等待审核')}
尚未提交计划
`; + return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${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)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.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 || '已处理')}`}
暂无投档考生
`; + const exportBar = data.completedExams?.length ? `
FINAL ROSTER正式录取考生信息 Excel仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。
` : ''; + return `${exportBar}

本校投档名单

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

${data.placements.length} 人
${data.placements.map(item => ``).join('') || ''}
考生资格当次成绩投档类别状态审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.candidate.specialtyCertificate || '')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}总分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}
${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}
暂无投档考生
`; } return { renderAdmission }; } +import { admissionCategoriesEditor } from './admission-plan-editor.mjs'; +import { specialtyLabel } from '../data/specialty-types.mjs'; diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 63330ab..c5eb7d1 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -1,4 +1,5 @@ import { mountRegionSelects } from './region-select.mjs'; +import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs'; export function createCandidateViews(context) { const { @@ -58,7 +59,9 @@ export function createCandidateViews(context) { function mountAdmissionProfileFields(profile = {}) { const actions = app.querySelector('.profile-form .form-actions'); if (!actions || app.querySelector('[data-admission-profile-fields]')) return; - actions.insertAdjacentHTML('beforebegin', `
04

中考招生资格

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

`); + const qualification = resolveProfileSpecialty(profile); + const selectedCategory = specialtyCatalog.find(item => item.code === qualification.category); + actions.insertAdjacentHTML('beforebegin', `
04

中考招生资格

特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。

`); } function onboardingShell(stage, content) { @@ -185,7 +188,7 @@ export function createCandidateViews(context) { const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified'; return `
${h(item.subjectName)}${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}
${h(item.score)} / ${h(item.fullScore)}${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%
本科排名${h(item.passText || '不设单科线')}
${appealPanel}
`; }).join(''); - const panel = `
${h(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。'}

已发布 ${items.length} 科
`; + const panel = `
${h(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名特征分${h(summary?.featureScore ?? 0)}独立于考试科目整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}

已发布 ${items.length} 科
`; return items[0].archivedAt ? `
${h(examName)}${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定历史成绩${panel}
` : panel; }).join('')}`; } @@ -196,11 +199,18 @@ export function createCandidateViews(context) { 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('')}
` : '
当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}
`; + const choiceRows = Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => { + const choice = choices[index] || {}; + const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); + const categoryOptions = (plan?.categories || []).filter(category => category.remaining > 0 || category.code === choice.categoryCode); + return `
${index + 1}
`; + }).join(''); + const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `${index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join(''); + const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生'; + 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(item.featureScore || 0)}资格 ${h(qualification)}${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)} 个
${choiceRows}
` : choices.length ? `
已锁定志愿顺序${lockedRows}
` : '
当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}
`; }).join('')}
`; } diff --git a/src/data/base.mjs b/src/data/base.mjs index 3efb7dc..3aa85c5 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: 18, createdAt }, + meta: { version: 19, createdAt }, settings: { selfRegistrationEnabled: false }, organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, schools: [], classes: [], diff --git a/src/data/specialty-types.mjs b/src/data/specialty-types.mjs new file mode 100644 index 0000000..04d9be4 --- /dev/null +++ b/src/data/specialty-types.mjs @@ -0,0 +1,76 @@ +export const specialtyCatalog = Object.freeze([ + Object.freeze({ + code: 'sports', + name: '体育', + types: Object.freeze([ + Object.freeze({ code: 'track_field', name: '田径' }), + Object.freeze({ code: 'basketball', name: '篮球' }), + Object.freeze({ code: 'football', name: '足球' }), + Object.freeze({ code: 'volleyball', name: '排球' }), + Object.freeze({ code: 'table_tennis', name: '乒乓球' }), + Object.freeze({ code: 'badminton', name: '羽毛球' }), + Object.freeze({ code: 'swimming', name: '游泳' }), + Object.freeze({ code: 'martial_arts', name: '武术' }), + Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' }) + ]) + }), + Object.freeze({ + code: 'arts', + name: '艺术', + types: Object.freeze([ + Object.freeze({ code: 'vocal_music', name: '声乐' }), + Object.freeze({ code: 'instrumental_music', name: '器乐' }), + Object.freeze({ code: 'dance', name: '舞蹈' }), + Object.freeze({ code: 'fine_arts', name: '美术' }), + Object.freeze({ code: 'calligraphy', name: '书法' }), + Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' }) + ]) + }) +]); + +const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category])); +const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }]))); +const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }]))); + +export function specialtyCategory(code) { + return categoryMap.get(String(code || '')) || null; +} + +export function specialtyType(code) { + return typeMap.get(String(code || '')) || null; +} + +export function isValidSpecialty(categoryCode, typeCode) { + if (!categoryCode && !typeCode) return true; + const category = specialtyCategory(categoryCode); + const type = specialtyType(typeCode); + return Boolean(category && type && type.categoryCode === category.code); +} + +export function resolveProfileSpecialty(profile = {}) { + if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) { + return { category: profile.specialtyCategory, type: profile.specialtyType }; + } + const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean); + return legacy || { category: '', type: '' }; +} + +export function specialtyLabel(categoryCode, typeCode) { + const category = specialtyCategory(categoryCode); + const type = specialtyType(typeCode); + if (!category) { + const legacy = legacyTypeMap.get(String(typeCode || '')); + return legacy ? specialtyLabel(legacy.category, legacy.type) : ''; + } + return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name; +} + +export function candidateEligibleForCategory(profile, category) { + const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null; + const requiredCategory = category?.specialtyCategory || legacy?.category || ''; + const requiredType = legacy?.type || category?.specialtyType || ''; + if (!requiredCategory) return true; + const qualification = resolveProfileSpecialty(profile); + if (qualification.category !== requiredCategory) return false; + return !requiredType || qualification.type === requiredType; +} diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 62f98b7..24ac565 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -54,7 +54,7 @@ export function createMysqlAdapter(context) { hasSchemaMetadata = metadataRows.length > 0; existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null; } - if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17].includes(existingSchemaVersion))) { + if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19].includes(existingSchemaVersion))) { for (const table of [...mysqlTableNames].reverse()) { await pool.query(`DROP TABLE IF EXISTS \`${table}\``); } @@ -177,6 +177,20 @@ export function createMysqlAdapter(context) { 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]?.schema_version || 1) < 19) { + const [schoolColumns] = await pool.query("SHOW COLUMNS FROM schools WHERE Field IN ('is_source_school', 'is_admission_school')"); + const existingSchoolColumns = new Set(schoolColumns.map(item => item.Field)); + if (!existingSchoolColumns.has('is_source_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_source_school BOOLEAN NOT NULL DEFAULT TRUE AFTER address'); + if (!existingSchoolColumns.has('is_admission_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_admission_school BOOLEAN NOT NULL DEFAULT TRUE AFTER is_source_school'); + const [specialtyColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_category', 'specialty_type')"); + const existingSpecialtyColumns = new Set(specialtyColumns.map(item => item.Field)); + if (!existingSpecialtyColumns.has('specialty_category')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_category VARCHAR(30) NULL AFTER guardian_phone'); + if (!existingSpecialtyColumns.has('specialty_type')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_type VARCHAR(40) NULL AFTER specialty_category'); + const [registrationColumns] = await pool.query("SHOW COLUMNS FROM registrations WHERE Field = 'feature_score'"); + if (!registrationColumns.length) await pool.query('ALTER TABLE registrations ADD COLUMN feature_score DECIMAL(8,2) NOT NULL DEFAULT 0 AFTER number_rule_id'); + await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1'); + metadataRows[0].schema_version = 19; + } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 2ac2e6b..5482e47 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -22,6 +22,8 @@ export const sqliteSchema = ` name TEXT NOT NULL UNIQUE, code TEXT NOT NULL UNIQUE, address TEXT, + is_source_school INTEGER NOT NULL DEFAULT 1 CHECK (is_source_school IN (0, 1)), + is_admission_school INTEGER NOT NULL DEFAULT 1 CHECK (is_admission_school IN (0, 1)), active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) ) STRICT; @@ -90,6 +92,8 @@ export const sqliteSchema = ` postal_code TEXT, guardian_name TEXT, guardian_phone TEXT, + specialty_category TEXT, + specialty_type TEXT, specialty_types TEXT NOT NULL DEFAULT '[]', specialty_certificate TEXT, policy_eligibility TEXT, @@ -174,6 +178,7 @@ export const sqliteSchema = ` review_note TEXT, registration_number TEXT, number_rule_id TEXT, + feature_score REAL NOT NULL DEFAULT 0 CHECK (feature_score >= 0), UNIQUE (user_id, exam_id) ) STRICT; @@ -521,6 +526,8 @@ export const mysqlSchema = [ name VARCHAR(160) NOT NULL, code VARCHAR(40) NOT NULL, address VARCHAR(255) NULL, + is_source_school BOOLEAN NOT NULL DEFAULT TRUE, + is_admission_school BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (id), UNIQUE KEY uq_schools_name (name), @@ -602,6 +609,8 @@ export const mysqlSchema = [ postal_code VARCHAR(20) NULL, guardian_name VARCHAR(100) NULL, guardian_phone VARCHAR(60) NULL, + specialty_category VARCHAR(30) NULL, + specialty_type VARCHAR(40) NULL, specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()), specialty_certificate VARCHAR(255) NULL, policy_eligibility VARCHAR(255) NULL, @@ -706,6 +715,7 @@ export const mysqlSchema = [ review_note VARCHAR(500) NULL, registration_number VARCHAR(120) NULL, number_rule_id VARCHAR(64) NULL, + feature_score DECIMAL(8,2) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), KEY idx_registrations_status (status), diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index 98f6331..c026afd 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -37,14 +37,18 @@ export function createSqliteAdapter(context) { ['archived_at', 'TEXT'], ['archived_by', 'TEXT'] ]); ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); + ensureColumns('schools', [ + ['is_source_school', 'INTEGER NOT NULL DEFAULT 1'], ['is_admission_school', 'INTEGER NOT NULL DEFAULT 1'] + ]); ensureColumns('candidate_profiles', [ ['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'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"], + ['specialty_category', 'TEXT'], ['specialty_type', 'TEXT'], ['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT'] ]); - ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); + ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT'], ['feature_score', 'REAL NOT NULL DEFAULT 0']]); ensureColumns('exams', [ ['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'], ['archived_at', 'TEXT'], ['archived_by', 'TEXT'] @@ -280,6 +284,9 @@ export function createSqliteAdapter(context) { 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.schema_version || 1) < 19) { + connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run(); + } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); connection.exec('BEGIN IMMEDIATE'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 84cdfce..30496ac 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,6 +1,7 @@ import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs'; import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs'; import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs'; +import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; export function createAdminRoutes(context) { const { @@ -112,7 +113,7 @@ export function createAdminRoutes(context) { function normalizeAdmissionCategories(input) { return (Array.isArray(input) ? input : []).map((item, index) => ({ code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80), - quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyType: cleanText(item.specialtyType, 80), + quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyCategory: cleanText(item.specialtyCategory, 30), specialtyType: cleanText(item.specialtyType, 80), indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({ sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0))) })).filter(item => item.sourceSchoolId && item.quota > 0) @@ -147,7 +148,8 @@ export function createAdminRoutes(context) { const placements = admissionRecords(db, 'placement').map(placement => { const account = db.users.find(item => item.id === placement.userId) || {}; const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {}; - return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [] }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' }; + const qualification = resolveProfileSpecialty(profile); + return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' }; }); const preferences = admissionRecords(db, 'preference').map(preference => { const account = db.users.find(item => item.id === preference.userId) || {}; @@ -155,12 +157,12 @@ export function createAdminRoutes(context) { return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) }; }); const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser); - return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), exams: db.exams.filter(item => !item.archivedAt) }); + return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) }); } if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') { if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号'); const body = await readJson(request); - const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool); const username = cleanText(body.username, 80); const password = String(body.password || ''); if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码'); @@ -191,11 +193,14 @@ export function createAdminRoutes(context) { if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划'); const body = await readJson(request); const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt); - const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool); const categories = normalizeAdmissionCategories(body.categories); if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划'); + if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复'); + if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应'); + if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标'); if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数'); - if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID'); + if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校'); const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id); if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划'); const now = nowIso(); @@ -305,11 +310,14 @@ export function createAdminRoutes(context) { const name = cleanText(body.name, 100); const code = cleanText(body.code, 40).toUpperCase(); const address = cleanText(body.address, 200); + const isSourceSchool = body.isSourceSchool !== false; + const isAdmissionSchool = body.isAdmissionSchool !== false; if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空'); + if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校'); if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符'); if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在'); if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在'); - const school = { id: uid('school'), name, code, address, active: body.active !== false }; + const school = { id: uid('school'), name, code, address, isSourceSchool, isAdmissionSchool, active: body.active !== false }; await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`)); return sendJson(response, 201, { ok: true, school }); } @@ -322,11 +330,14 @@ export function createAdminRoutes(context) { const name = cleanText(body.name ?? school.name, 100); const code = cleanText(body.code ?? school.code, 40).toUpperCase(); const address = cleanText(body.address ?? school.address, 200); + const isSourceSchool = body.isSourceSchool == null ? school.isSourceSchool : Boolean(body.isSourceSchool); + const isAdmissionSchool = body.isAdmissionSchool == null ? school.isAdmissionSchool : Boolean(body.isAdmissionSchool); if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空'); + if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校'); if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符'); if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在'); if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在'); - Object.assign(school, { name, code, address, active: body.active == null ? school.active : Boolean(body.active) }); + Object.assign(school, { name, code, address, isSourceSchool, isAdmissionSchool, active: body.active == null ? school.active : Boolean(body.active) }); await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`)); return sendJson(response, 200, { ok: true, school }); } @@ -343,6 +354,7 @@ export function createAdminRoutes(context) { } if (pathname === '/api/admin/classes' && request.method === 'POST') { if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级'); + if (!db.schools.some(item => item.id === user.schoolId && item.active && item.isSourceSchool)) return sendError(response, 409, '当前学校未设置为已启用的生源校'); const body = await readJson(request); const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60); if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); @@ -374,7 +386,7 @@ export function createAdminRoutes(context) { schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '' })); - return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled }); + return sendJson(response, 200, { ok: true, admins, schools: db.schools.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled }); } if (pathname === '/api/admin/admins' && request.method === 'POST') { const body = await readJson(request); @@ -387,7 +399,7 @@ export function createAdminRoutes(context) { if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在'); const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64); const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null; - if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校'); + if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId && item.active && item.isSourceSchool)) return sendError(response, 400, '校级和班级管理员必须绑定已启用的生源校'); if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级'); const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() }; const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`); @@ -425,7 +437,7 @@ export function createAdminRoutes(context) { .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) .map(item => candidateAccountBatchView(db, item)); const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)); - return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) }); + return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active && item.isSourceSchool) }); } if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') { if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领'); @@ -1293,6 +1305,21 @@ export function createAdminRoutes(context) { const result = await commitResultImport(db, user, body.rows); return sendJson(response, 200, { ok: true, ...result }); } + const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/); + if (request.method === 'PATCH' && featureScoreMatch) { + if (!requirePermission(user, response, '*')) return true; + const registration = db.registrations.find(item => item.id === featureScoreMatch[1] && item.status === 'approved'); + if (!registration) return sendError(response, 404, '已通过的报名记录不存在'); + const exam = db.exams.find(item => item.id === registration.examId); + if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,特征分已永久锁定'); + const body = await readJson(request); + const featureScore = Number(body.featureScore); + if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, '特征分必须在 0—1000 之间'); + registration.featureScore = Number(featureScore.toFixed(2)); + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + await database.updateFeatureScore(registration, logAction(db, user, '登记特征分', `${profile?.name || registration.userId} · ${exam?.name || registration.examId} · ${registration.featureScore}`)); + return sendJson(response, 200, { ok: true, registration }); + } if (request.method === 'POST' && pathname === '/api/admin/results') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs index 5e0ad78..09066e1 100644 --- a/src/routes/admission.routes.mjs +++ b/src/routes/admission.routes.mjs @@ -1,4 +1,5 @@ import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs'; +import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; function normalizeCategories(input, cleanText) { const source = Array.isArray(input) ? input : []; @@ -6,6 +7,7 @@ function normalizeCategories(input, cleanText) { code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80), quota: Math.max(0, Math.trunc(Number(item.quota || 0))), + specialtyCategory: cleanText(item.specialtyCategory, 30), specialtyType: cleanText(item.specialtyType, 80), indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({ sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0))) @@ -14,14 +16,14 @@ function normalizeCategories(input, cleanText) { } export function createAdmissionRoutes(context) { - const { database, readDb, sendJson, sendError, readJson, requireUser, cleanText, maskId, uid, nowIso, logAction } = context; + const { database, readDb, sendJson, sendError, readJson, sendWorkbook, buildWorkbook, 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); + const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool); if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校'); if (request.method === 'GET' && pathname === '/api/admission/context') { @@ -29,7 +31,7 @@ export function createAdmissionRoutes(context) { } 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) }); + return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) }); } if (request.method === 'POST' && pathname === '/api/admission/plans') { const body = await readJson(request); @@ -37,8 +39,11 @@ export function createAdmissionRoutes(context) { if (!exam) return sendError(response, 404, '考试不存在或已经归档'); const categories = normalizeCategories(body.categories, cleanText); if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数'); + if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复'); + if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应'); + if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标'); if (categories.some(item => item.indicatorAllocations.reduce((sum, 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'); + if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校'); const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id); if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整'); const now = nowIso(); @@ -56,9 +61,37 @@ export function createAdmissionRoutes(context) { 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 }; + const qualification = resolveProfileSpecialty(profile); + return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results }; }); - return sendJson(response, 200, { ok: true, school, placements }); + const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final')); + return sendJson(response, 200, { ok: true, school, placements, completedExams }); + } + if (request.method === 'GET' && pathname === '/api/admission/placements/export') { + const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64); + const exam = db.exams.find(item => item.id === examId); + const setting = admissionRecords(db, 'setting', examId)[0]; + if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单'); + const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').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 === examId && entry.userId === item.userId) || {}; + const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {}; + const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {}; + const qualification = resolveProfileSpecialty(profile); + const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score })); + return { + candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '', + idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '', + sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '', + address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '', + specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '', + featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(';'), totalScore: Number(item.payload?.totalScore || 0), + admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0) + }; + }); + const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` })); + return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`); } const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/); if (request.method === 'PATCH' && placementMatch) { diff --git a/src/routes/auth.routes.mjs b/src/routes/auth.routes.mjs index 87dd252..705e390 100644 --- a/src/routes/auth.routes.mjs +++ b/src/routes/auth.routes.mjs @@ -115,7 +115,7 @@ export function createAuthRoutes(context) { if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录'); const schoolId = cleanText(body.schoolId, 64); const classId = cleanText(body.classId, 64); - const school = db.schools.find(item => item.id === schoolId && item.active); + const school = db.schools.find(item => item.id === schoolId && item.active && item.isSourceSchool); const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active); if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); const draftProfile = { schoolId, classId, gender }; diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 5ad02d8..6ca230b 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -1,5 +1,6 @@ import { noticeForClient } from '../security/notice-content.mjs'; import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs'; +import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs'; export function createCandidateRoutes(context) { const { @@ -78,17 +79,20 @@ export function createCandidateRoutes(context) { if (request.method === 'GET' && pathname === '/api/candidate/profile') { const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0]; - return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) }); + return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active) }); } 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', '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); + profile.specialtyCategory = cleanText(body.specialtyCategory, 30); + profile.specialtyType = cleanText(body.specialtyType, 40); + if (!isValidSpecialty(profile.specialtyCategory, profile.specialtyType)) return sendError(response, 400, '请选择对应的特长生大类和小类'); + profile.specialtyTypes = profile.specialtyType ? [profile.specialtyType] : []; const region = resolveRegion(body); if (!region) return sendError(response, 400, '请选择有效的省、市和区县'); Object.assign(profile, region); - const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isSourceSchool); const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active); if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); profile.schoolId = school.id; @@ -159,11 +163,15 @@ export function createCandidateRoutes(context) { 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) }; + const plans = approvedPlans(db, setting.examId).map(plan => { + const school = db.schools.find(item => item.id === plan.schoolId); + return { + id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '', + categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)) + }; + }).filter(plan => plan.categories.length); + const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id); + return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile) }; }).filter(item => item.exam); const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id); return sendJson(response, 200, { ok: true, admissions: settings, notifications }); @@ -183,7 +191,7 @@ export function createCandidateRoutes(context) { 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, '志愿中包含未审核通过的学校或招生类别'); + if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode && candidateEligibleForCategory(profile, category))))) 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 }; diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index d22e9b3..21183f2 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -59,7 +59,7 @@ export function createPublicRoutes(context) { const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient); const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); const 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 { 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 && item.isSourceSchool), 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 index 8cf2580..da2dbb1 100644 --- a/src/services/volunteer-admission.mjs +++ b/src/services/volunteer-admission.mjs @@ -82,12 +82,11 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { 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; + if (!candidateEligibleForCategory(candidate.profile, category)) 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); @@ -110,7 +109,8 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { 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: '' + totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0), + specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: '' } }); break; @@ -125,3 +125,4 @@ export function remainingPlanQuota(db, plan) { return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) }; }); } +import { candidateEligibleForCategory, resolveProfileSpecialty } from '../data/specialty-types.mjs'; diff --git a/styles.css b/styles.css index e1783a7..b44d729 100644 --- a/styles.css +++ b/styles.css @@ -593,3 +593,32 @@ button:disabled { cursor: not-allowed; opacity: .5; } .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; } } + +/* 招生资格与计划台账:配额卡片是本轮的唯一结构化视觉重点。 */ +.admission-plan-console.structured { padding:22px; margin-bottom:16px; } +.admission-categories-builder { display:grid; gap:12px; } +.admission-builder-head,.indicator-allocation-head { display:flex; align-items:center; justify-content:space-between; gap:16px; } +.admission-builder-head small,.indicator-allocation-head small { display:block; margin-top:3px; color:var(--muted); } +[data-admission-categories] { display:grid; gap:13px; } +.admission-category-editor { overflow:hidden; border:1px solid #d8e2ec; border-radius:12px; background:#fbfdfe; } +.admission-category-editor > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 14px; color:#fff; background:linear-gradient(100deg,#244e72,#287486); } +.admission-category-editor > header div { display:flex; align-items:center; gap:10px; }.admission-category-editor > header span { color:#b9d8df; font:700 10px Consolas,monospace; }.admission-category-editor > header strong { font-size:13px; } +.admission-category-editor > header button { border:0; color:#dbeef1; background:transparent; cursor:pointer; } +.admission-category-fields { display:grid; grid-template-columns:1.2fr .55fr .7fr; gap:11px; padding:14px; } +.specialty-plan-fields { grid-column:1/-1; display:grid; grid-template-columns:1fr 1fr; gap:11px; padding:12px; border-left:3px solid #2b7e89; border-radius:8px; background:#edf7f7; }.specialty-plan-fields.hidden { display:none; } +.indicator-allocation-editor { padding:0 14px 14px; }.indicator-allocation-head { padding-top:12px; border-top:1px solid #dfe7ee; } +[data-indicator-allocations] { display:grid; gap:8px; margin-top:10px; }.indicator-allocation-row { display:grid; grid-template-columns:1fr 150px auto; align-items:end; gap:9px; padding:10px; border-radius:9px; background:#f1f5f8; } +.indicator-allocation-row label,.admission-category-fields label { display:grid; gap:5px; }.indicator-allocation-row label span,.admission-category-fields label > span,.school-role-selector > strong { color:#607087; font-size:11px; font-weight:700; } +.indicator-allocation-row input,.indicator-allocation-row select,.admission-category-fields input,.admission-category-fields select { width:100%; min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; } +.preference-choice-row { display:grid; grid-template-columns:34px 1fr 1fr; align-items:end; gap:10px; padding:11px; border:1px solid #dce5eb; border-radius:10px; background:#f8fbfc; } +.preference-choice-list .preference-choice-row > b { align-self:center; }.preference-choice-list .preference-choice-row > label { display:grid; grid-template-columns:1fr; align-items:stretch; gap:5px; }.preference-choice-row label > span { color:#687789; font-size:10px; font-weight:700; } +.admission-score-strip > span b { margin-left:4px; color:#244e72; }.admission-score-strip > span:not(:first-child) { padding-left:12px; border-left:1px solid #cadde1; } +.school-type-summary { grid-template-columns:repeat(4,1fr); }.school-role-tags { display:flex; flex-wrap:wrap; gap:5px; }.school-role { padding:5px 8px; border-radius:99px; font-size:11px; font-weight:700; }.school-role.source { color:#245c72; background:#e5f2f7; }.school-role.admission { color:#3c6651; background:#e5f3eb; } +.school-role-selector { display:grid; gap:9px; padding:13px; border:1px solid var(--line); border-radius:10px; background:#f7f9fc; }.school-role-selector > div { display:grid; grid-template-columns:1fr 1fr; gap:9px; }.school-role-selector label { display:grid; grid-template-columns:auto 1fr; gap:9px; padding:11px; border:1px solid #dce3ed; border-radius:8px; background:#fff; }.school-role-selector label span { display:grid; gap:3px; }.school-role-selector small { color:var(--muted); } +.feature-score-console { display:grid; grid-template-columns:minmax(260px,.8fr) minmax(420px,1.2fr); align-items:end; gap:24px; margin-bottom:15px; padding:21px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#edf7f7); }.feature-score-console > div > span,.admission-export-bar > div > span { color:#2c7180; font:700 10px Consolas,monospace; letter-spacing:1.2px; }.feature-score-console h2 { margin:5px 0; }.feature-score-console p { margin:0; color:var(--muted); line-height:1.7; }.feature-score-console form { display:grid; grid-template-columns:1fr 130px auto; align-items:end; gap:10px; }.feature-score-console label { display:grid; gap:5px; }.feature-score-console select,.feature-score-console input,.admission-export-bar select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; } +.admission-export-bar { display:grid; grid-template-columns:1fr minmax(250px,.5fr) auto; align-items:end; gap:18px; margin-bottom:15px; padding:20px 22px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#eef7f8); }.admission-export-bar > div { display:grid; gap:4px; }.admission-export-bar label { display:grid; gap:5px; }.admission-export-bar small { color:var(--muted); } +.specialty-qualification-grid select:disabled { color:#8f99a8; background:#f1f3f6; } +.result-panel .result-summary { grid-template-columns:repeat(4,1fr); } +.result-panel .result-summary.qualified > span:last-child strong,.result-panel .result-summary.unqualified > span:last-child strong { color:var(--navy); }.result-panel .result-summary.qualified > span:nth-child(3) strong { color:#237358; }.result-panel .result-summary.unqualified > span:nth-child(3) strong { color:#a24f43; } +@media (max-width:1000px) { .admission-category-fields { grid-template-columns:1fr 1fr; }.admission-category-fields > label:first-child { grid-column:1/-1; }.feature-score-console,.admission-export-bar { grid-template-columns:1fr; }.school-type-summary { grid-template-columns:repeat(2,1fr); } } +@media (max-width:620px) { .admission-category-fields,.specialty-plan-fields,.indicator-allocation-row,.preference-choice-row,.school-role-selector > div,.feature-score-console form,.school-type-summary { grid-template-columns:1fr; }.admission-category-fields > label:first-child { grid-column:auto; }.preference-choice-list .preference-choice-row > b { justify-self:start; }.admission-builder-head,.indicator-allocation-head { align-items:flex-start; flex-direction:column; }.result-panel .result-summary { grid-template-columns:1fr; } } diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs index b97db23..4fad4e8 100644 --- a/tests/admission.test.mjs +++ b/tests/admission.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs'; +import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs'; const now = '2026-07-21T08:00:00.000Z'; let sequence = 0; @@ -12,7 +13,7 @@ const db = { 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: ['田径'] } + { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] } ], schools: [ { id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' }, @@ -21,7 +22,7 @@ const db = { 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'] } + { id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 88.5 } ], results: [ { registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true }, @@ -32,7 +33,7 @@ const db = { { 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 }] } + { code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', 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' }] } }, @@ -47,6 +48,9 @@ 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', '特长生指标应使用对应生源学校指标名额'); +assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送,但不并入文化课总分'); +assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类'); +assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划'); db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' }))); const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b')); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 6a1a4f9..5d2d6a7 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -47,7 +47,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行'); assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建'); assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行'); -assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v17 结构并重建更旧或未完成的开发结构'); +assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v19 结构并重建更旧或未完成的开发结构'); assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理'); const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8'); assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器'); @@ -145,6 +145,7 @@ const classAdmin2 = createClient(); const candidate = createClient(); const selfCandidate = createClient(); const batchCandidate = createClient(); +const admissionSchoolClient = createClient(); const anonymous = createClient(); try { @@ -185,6 +186,8 @@ try { const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name); const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name); const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name); + const schoolColumns = inspector.prepare('PRAGMA table_info(schools)').all().map(row => row.name); + const profileColumns = inspector.prepare('PRAGMA table_info(candidate_profiles)').all().map(row => row.name); const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name); const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count; const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count; @@ -229,7 +232,7 @@ try { inspector.close(); assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); - assert.equal(schemaVersion, 18, '志愿填报与招生录取应使用 v18 数据结构'); + assert.equal(schemaVersion, 19, '学校类型、特长资格与特征分应使用 v19 数据结构'); 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), '考试专属表应与该场考试的考生、准考信息和成绩数据一致'); @@ -240,7 +243,10 @@ try { assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩'); assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人'); assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片'); + assert.ok(['is_source_school', 'is_admission_school'].every(column => schoolColumns.includes(column)), '学校档案应统一保存生源校和招生校类型'); + assert.ok(['specialty_category', 'specialty_type'].every(column => profileColumns.includes(column)), '考生档案应保存特长生大类和小类'); assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人'); + assert.ok(registrationColumns.includes('feature_score'), '每场考试报名应有独立且默认 0 分的特征分'); assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校'); assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生'); assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录'); @@ -342,11 +348,15 @@ try { assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务'); const updateProfile = await candidate.request('/api/candidate/profile', { method: 'PUT', - body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' } + body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888', specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyCertificate: 'ART-2026-001' } }); assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料'); assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成'); assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核'); + assert.equal(updateProfile.data.profile.specialtyCategory, 'arts', '考生资料应保存艺术大类资格'); + assert.equal(updateProfile.data.profile.specialtyType, 'fine_arts', '考生资料应保存对应的美术小类资格'); + const mismatchedSpecialty = await candidate.request('/api/candidate/profile', { method: 'PUT', body: { ...updateProfile.data.profile, schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', specialtyCategory: 'arts', specialtyType: 'track_field' } }); + assert.equal(mismatchedSpecialty.response.status, 400, '艺术大类不得选择体育小类'); const refreshedSession = await candidate.request('/api/auth/me'); assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步'); const candidateCannotAdmin = await candidate.request('/api/admin/dashboard'); @@ -367,7 +377,16 @@ try { const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } }); assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校'); assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写'); + assert.equal(createSchool.data.school.isSourceSchool, true, '新建学校默认兼容生源校职责'); + assert.equal(createSchool.data.school.isAdmissionSchool, true, '新建学校默认兼容招生校职责'); const createdSchoolId = createSchool.data.school.id; + const admissionOnlySchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市招生实验学校', code: 'HZ-ADMISSION', isSourceSchool: false, isAdmissionSchool: true, active: true } }); + assert.equal(admissionOnlySchool.response.status, 201, '学校管理应支持只设置为招生校'); + assert.equal(admissionOnlySchool.data.school.isSourceSchool, false); + assert.ok(!(await anonymous.request('/api/public/home')).data.schools.some(item => item.id === admissionOnlySchool.data.school.id), '仅招生校不得出现在考生生源学校选择中'); + const admissionAccount = await admin.request('/api/admin/admission-school-accounts', { method: 'POST', body: { schoolId: admissionOnlySchool.data.school.id, username: 'admission_only_test', password: 'Admission123!', displayName: '招生实验校招办' } }); + assert.equal(admissionAccount.response.status, 201, '招生校应可创建招生学校账号'); + assert.equal((await admissionSchoolClient.request('/api/auth/login', { method: 'POST', body: { username: 'admission_only_test', password: 'Admission123!' } })).response.status, 200, '招生学校账号应可登录独立工作台'); assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一'); const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } }); assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校'); @@ -435,6 +454,12 @@ try { assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/); const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data); assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头'); + const admittedWorkbook = new ExcelJS.Workbook(); + await admittedWorkbook.xlsx.load(await buildWorkbook('admitted_candidates', [{ candidateNumber: '20260001', name: '录取考生', featureScore: 87.5, totalScore: 650, admittedSchool: '第一中学', categoryName: '艺术特长生', preferenceOrder: 1 }])); + const admittedSheet = admittedWorkbook.getWorksheet('录取考生'); + assert.equal(admittedSheet.getCell('A3').value, '20260001', '录取考生 Excel 应包含报名号'); + assert.ok(admittedSheet.getRow(2).values.includes('特征分'), '录取考生 Excel 应单列特征分'); + assert.ok(admittedSheet.getRow(2).values.includes('录取学校'), '录取考生 Excel 应包含录取学校'); const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }])); const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile }); assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级'); @@ -552,6 +577,15 @@ try { assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); const exam = createExam.data.exam; + assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5 } })).response.status, 200, '超级管理员应能按考试启用志愿功能'); + const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [ + { code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] }, + { code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] } + ] } }); + assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划'); + assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts'); + const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } }); + assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用'); const createdExamInspector = new DatabaseSync(testDb, { readOnly: true }); const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id); assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表'); @@ -843,6 +877,11 @@ try { assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分'); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200); const adminResults = await admin.request('/api/admin/results'); + assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0'); + const featureScoreUpdate = await admin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 87.5 } }); + assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能登记与科目无关的特征分'); + assert.equal(featureScoreUpdate.data.registration.featureScore, 87.5); + assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分'); assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用'); assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存'); const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' }); @@ -851,6 +890,7 @@ try { const results = await candidate.request('/api/candidate/results'); assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); const resultSummary = results.data.summaries.find(item => item.examId === exam.id); + assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分'); assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分'); assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总'); assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格'); From 6be1ff227aeb3511b77ca6fdf1c88bd6f90dbc57 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 14:37:06 +0800 Subject: [PATCH 4/5] Add indicator qualification and admissions announcements --- README.md | 13 +++--- app.js | 22 +++++++--- database.mjs | 2 +- src/client/admin-views.mjs | 10 ++++- src/client/candidate-views.mjs | 26 ++++++----- src/client/public-views.mjs | 20 +++++++-- src/client/state.mjs | 3 +- src/database/mysql-adapter.mjs | 7 ++- src/database/schema.mjs | 4 +- src/database/sqlite-adapter.mjs | 24 ++++++++++ src/routes/admin.routes.mjs | 53 +++++++++++++++++++--- src/routes/candidate.routes.mjs | 55 ++++++++++++++++++----- src/routes/public.routes.mjs | 18 ++++++-- src/services/volunteer-admission.mjs | 66 +++++++++++++++++++++++++--- styles.css | 18 ++++++++ tests/admission.test.mjs | 27 +++++++----- tests/system.test.mjs | 34 ++++++++++++-- 17 files changed, 332 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 81b7a59..e881cb1 100644 --- a/README.md +++ b/README.md @@ -260,18 +260,19 @@ npm run seed-test-data:mysql -- --force 系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下: -1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。 +1. 超级管理员设置填报时间、普通志愿数、最多提交次数和当前阶段;考生只有在当次成绩全部发布后才能填报,达到提交上限后自动锁定。 2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。 -3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。 -4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。 -5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 -6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。 +3. 生源校学校管理员按考试逐人确认指标分配资格;本校资料已完善的在册考生全部确认后,系统自动公开有无资格及对应特长类型。超级管理员和班级管理员均不能代确认。 +4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。 +5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。 +6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 +7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。 公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。 学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。 -数据结构版本为 v19,`admission_records` 关系表支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 +数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 ## 手动测试数据账号 diff --git a/app.js b/app.js index da8fb01..5a2d5f0 100644 --- a/app.js +++ b/app.js @@ -59,7 +59,7 @@ function renderError(error) { } const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; -const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); +const { brand, renderHome, renderAnnouncements, 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 }); @@ -75,6 +75,7 @@ async function renderRoute() { const route = location.hash.slice(1) || 'home'; const [section, page = 'dashboard'] = route.split('/'); if (section === 'home') renderHome(); + else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); } else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); @@ -221,6 +222,14 @@ document.addEventListener('click', async event => { await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } }); toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute(); } + if (action === 'save-indicator-qualification') { + const row = target.closest('tr'); + const value = row?.querySelector('[data-indicator-eligible]')?.value; + if (!value) return toast('请选择资格结论', '必须明确选择有资格或无资格'); + const data = await api(`/api/admin/indicator-qualifications/${row.dataset.examId}/${row.dataset.userId}`, { method: 'PUT', body: { eligible: value === 'true' } }); + toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生'); + return renderRoute(); + } if (action === 'add-admission-category') { const sources = state.pageData?.sourceSchools || []; target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources)); @@ -510,9 +519,10 @@ document.addEventListener('change', event => { const categorySelect = row?.querySelector('[name="choiceCategory"]'); const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId); const plan = admission?.plans?.find(item => item.schoolId === event.target.value); + const preferenceType = row?.dataset.preferenceType || 'general'; if (categorySelect) { categorySelect.disabled = !plan; - categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.remaining > 0).map(item => ``).join('')}`; + categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => ``).join('')}`; } } if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target); @@ -617,9 +627,9 @@ document.addEventListener('submit', async event => { 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 = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value })).filter(item => item.schoolId && item.categoryCode); - await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); - toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute(); + const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value, preferenceType: row.dataset.preferenceType || 'general' })).filter(item => item.schoolId && item.categoryCode); + const data = await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); + toast(data.locked ? '志愿已保存并锁定' : '志愿已保存', data.locked ? '已达到本轮提交次数上限' : `还可提交 ${data.remainingSubmissions} 次`); 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('请至少选择一个报考科目'); @@ -642,7 +652,7 @@ document.addEventListener('submit', async event => { 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); + const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); body.maxSubmissions = Number(body.maxSubmissions || 3); await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body }); toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute(); } else if (kind === 'admission-account') { diff --git a/database.mjs b/database.mjs index 134d600..9f124ee 100644 --- a/database.mjs +++ b/database.mjs @@ -60,7 +60,7 @@ export function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 19, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 20, 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() ); diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 44e378e..da5b702 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -47,6 +47,7 @@ export function createAdminViews(context) { 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'], admissions: ['招生录取', '设置志愿窗口、审核招生计划,并按分数优先、遵循志愿执行投档、退档审核和补录。'], + 'indicator-qualifications': ['指标分配资格确认', '由生源校逐人确认;本校全部考生确认后,系统自动发布资格公示。'], security: ['账户安全', '使用当前密码设置新的登录密码。'] }; const allowedPages = adminNavForUser().map(item => item[0]); @@ -60,7 +61,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: () => adminSchoolsV2(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), admissions: () => adminAdmissionsV2(data), security: () => accountSecurity(data) + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); if (page === 'admissions') mountPreferenceLedger(data); @@ -166,7 +167,7 @@ export function createAdminViews(context) { const pendingPlans = data.plans.filter(item => item.status === 'pending'); const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }; - const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
` : ''}
`; + const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
` : ''}
`; const accounts = ``; const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
`; @@ -174,6 +175,11 @@ export function createAdminViews(context) { return `
ADMISSION COMMAND

中考招生录取控制台

学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
${settings}${accounts}
${planForm}${plans}${placements}`; } + function adminIndicatorQualifications(data) { + if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。'); + return `
SOURCE SCHOOL CERTIFICATION

${h(data.school?.name)}资格确认簿

确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。

${data.exams.map(item => { const status = item.qualificationStatus; return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}
${status.rows.map(row => ``).join('') || ''}
报名号 / 姓名特长类型指标分配资格确认时间保存
${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}
本校暂无资料已完善的在册考生
`; }).join('')}`; + } + 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/candidate-views.mjs b/src/client/candidate-views.mjs index c5eb7d1..b788c0c 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -30,7 +30,7 @@ export function createCandidateViews(context) { const security = ['security', '账户安全', 'user']; 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]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], 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'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; } @@ -198,19 +198,25 @@ export function createCandidateViews(context) { 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 canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked; 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)); - const choiceRows = Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => { - const choice = choices[index] || {}; - const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); - const categoryOptions = (plan?.categories || []).filter(category => category.remaining > 0 || category.code === choice.categoryCode); - return `
${index + 1}
`; - }).join(''); - const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `${index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join(''); + const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {}; + const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator'); + const indicatorEligible = item.indicatorQualification?.payload?.eligible === true; + const choiceRow = (choice, preferenceType, index) => { + const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType))); + const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId); + const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode)); + const disabled = preferenceType === 'indicator' && !indicatorEligible; + return `
${preferenceType === 'indicator' ? '指标' : index + 1}
`; + }; + const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join(''); + const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join(''); const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生'; - 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(item.featureScore || 0)}资格 ${h(qualification)}${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)} 个
${choiceRows}
` : choices.length ? `
已锁定志愿顺序${lockedRows}
` : '
当前不能填报:请等待成绩完整发布或志愿填报窗口开放。
'}
`; + const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格'; + 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(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${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 ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}
`; }).join('')}
`; } diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 9e9bba9..57a77f4 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, admissionAnnouncements = [] } = state.publicData; + const { notices, exams, stats, organization } = state.publicData; const siteCopy = state.publicData.siteCopy || {}; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const topNotice = notices[0]; @@ -37,11 +37,23 @@ 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('')}
` : ''} +

ADMISSION DISCLOSURE

招生录取公开卷宗

指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。

OPEN EXAMINATIONS

考试报名

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

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

SERVICE FLOW

报名号是唯一账户

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

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

${item[1]}

${item[2]}

`).join('')}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

${organization.address || organization.email ? `

${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}

` : ''}
${h(siteCopy.footerNotice || '')}
`; } + + function renderAnnouncements(data = state.publicAnnouncements) { + app.classList.remove('admin-readable'); + const organization = state.publicData.organization || {}; + const sections = [data.qualifications?.length, data.admissions?.length, data.cutoffs?.length].filter(Boolean).length; + app.innerHTML = `${publicHeader()}

PUBLIC ADMISSION LEDGER

招生录取
公开卷宗

按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。

公开类别
${sections}
资格公示
${h(data.qualifications?.length || 0)}
录取公告
${h(data.admissions?.length || 0)}
+ +

QUALIFICATION REGISTER

指标分配资格公示

仅在生源校全部考生完成资格确认后自动发布。

${(data.qualifications || []).map(item => `
${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}

${h(item.examName)}

${h(item.rows.length)} 人
${item.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`).join('') || '
暂无已完成全校确认的资格公示
'}
+

ADMISSION RESULTS

最终录取名单

录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。

${(data.admissions || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 人录取
${item.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`).join('') || '
暂无已完成的录取公告
'}
+

ADMISSION CUTOFFS

录取分数线统计

分数线为对应学校、招生类别最终录取考生的最低总成绩。

${(data.cutoffs || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 条分数线
${item.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`).join('') || '
暂无已发布的录取分数线
'}
+
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

招生公示数据由系统按业务状态自动生成
`; + } function renderHeroTicket(exam) { const status = exam.registrationState; @@ -72,5 +84,5 @@ export function createPublicViews(context) { return `
`; } - return { brand, renderHome, renderAuth }; + return { brand, renderHome, renderAnnouncements, renderAuth }; } diff --git a/src/client/state.mjs b/src/client/state.mjs index 4932421..f5a1a00 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -1,7 +1,8 @@ export const state = { user: null, profile: null, - publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} }, + publicData: { organization: {}, notices: [], exams: [], stats: {} }, + publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] }, permissions: [], scopeLabel: '', pageData: null, diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 24ac565..5c98b43 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -54,7 +54,7 @@ export function createMysqlAdapter(context) { hasSchemaMetadata = metadataRows.length > 0; existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null; } - if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19].includes(existingSchemaVersion))) { + if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19, 20].includes(existingSchemaVersion))) { for (const table of [...mysqlTableNames].reverse()) { await pool.query(`DROP TABLE IF EXISTS \`${table}\``); } @@ -191,6 +191,11 @@ export function createMysqlAdapter(context) { await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1'); metadataRows[0].schema_version = 19; } + if (Number(metadataRows[0]?.schema_version || 1) < 20) { + await pool.query("ALTER TABLE admission_records MODIFY COLUMN kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL"); + await pool.execute('UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1'); + metadataRows[0].schema_version = 20; + } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 5482e47..7edd605 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -436,7 +436,7 @@ export const sqliteSchema = ` CREATE TABLE IF NOT EXISTS admission_records ( id TEXT PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification')), + kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')), 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, @@ -1018,7 +1018,7 @@ export const mysqlSchema = [ ) 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, + kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL, exam_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NULL, school_id VARCHAR(64) NULL, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index c026afd..bc8d405 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -287,6 +287,30 @@ export function createSqliteAdapter(context) { if (existingSystem && Number(existingSystem.schema_version || 1) < 19) { connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run(); } + if (existingSystem && Number(existingSystem.schema_version || 1) < 20) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE admission_records_v20 ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')), + 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; + INSERT INTO admission_records_v20 SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records; + DROP TABLE admission_records; + ALTER TABLE admission_records_v20 RENAME TO admission_records; + CREATE INDEX idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status); + UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); connection.exec('BEGIN IMMEDIATE'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 30496ac..44cf998 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,6 +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'; +import { admissionCutoffRows, admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; export function createAdminRoutes(context) { @@ -141,6 +141,44 @@ export function createAdminRoutes(context) { }); } + if (pathname === '/api/admin/indicator-qualifications' && request.method === 'GET') { + if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格'); + const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isSourceSchool); + if (!school) return sendError(response, 403, '当前学校未设置为生源学校'); + const exams = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => { + const exam = db.exams.find(item => item.id === setting.examId); + return { ...setting, exam: exam ? publicExam(exam) : null, qualificationStatus: sourceSchoolQualificationStatus(db, setting.examId, school.id) }; + }).filter(item => item.exam); + return sendJson(response, 200, { ok: true, school, exams }); + } + const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/); + if (qualificationMatch && request.method === 'PUT') { + if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格'); + const setting = admissionSetting(db, qualificationMatch[1]); + const profile = db.candidateProfiles.find(item => item.userId === qualificationMatch[2] && item.schoolId === user.schoolId && item.profileCompleted); + const account = db.users.find(item => item.id === profile?.userId && item.role === 'candidate' && item.active); + if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报'); + if (!profile || !account) return sendError(response, 404, '本校有效考生不存在'); + const body = await readJson(request); + if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择有或无指标分配资格'); + const now = nowIso(); + const existing = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === profile.userId); + const qualification = existing || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now }; + Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } }); + const nextRecords = [...db.admissionRecords.filter(item => item.id !== qualification.id), qualification]; + const nextDb = { ...db, admissionRecords: nextRecords }; + const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId); + const records = [qualification]; + if (status.complete) { + const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId); + const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now }; + Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } }); + records.push(publication); + } + await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`)); + return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' }); + } + 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) : [] })); @@ -184,7 +222,7 @@ export function createAdminRoutes(context) { const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft'); setting.status = status; setting.updatedAt = now; - setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' }; + 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)))), maxSubmissions: Math.min(50, Math.max(1, Math.trunc(Number(body.maxSubmissions || 3)))), 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 }); @@ -241,9 +279,14 @@ export function createAdminRoutes(context) { 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) }); + setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果与分数线已经自动公告'; setting.payload.completedAt = now; + const completedDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }; + const cutoffRows = admissionCutoffRows(completedDb, setting.examId); + const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0]; + const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now }; + Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: cutoffRows } }); + await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length} 人`)); + return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows }); } const body = await readJson(request); const now = nowIso(); diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 6ca230b..972c94e 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -1,5 +1,5 @@ import { noticeForClient } from '../security/notice-content.mjs'; -import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs'; +import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.mjs'; import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs'; export function createCandidateRoutes(context) { @@ -162,16 +162,29 @@ export function createCandidateRoutes(context) { 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 qualification = indicatorQualification(db, setting.examId, user.id); const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn'); const plans = approvedPlans(db, setting.examId).map(plan => { const school = db.schools.find(item => item.id === plan.schoolId); + const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.status !== 'withdrawn'); return { id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '', - categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)) + categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => { + const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId); + const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length; + const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0)); + const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length; + const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed); + const generalRemaining = Math.max(0, generalQuota - generalUsed); + const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean); + return { ...category, generalRemaining, indicatorRemaining, preferenceTypes }; + }).filter(category => category.preferenceTypes.length) }; }).filter(plan => plan.categories.length); const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id); - return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile) }; + const submissionCount = Number(preference?.payload?.submissionCount || 0); + const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3)); + return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions }; }).filter(item => item.exam); const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id); return sendJson(response, 200, { ok: true, admissions: settings, notifications }); @@ -187,17 +200,37 @@ export function createCandidateRoutes(context) { 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 && candidateEligibleForCategory(profile, category))))) return sendError(response, 400, '志愿中包含未审核通过或与本人资格不符的招生类别'); const round = Number(setting.payload.round || 1); + const currentPreference = activePreference(db, setting.examId, user.id, round); + const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3)); + const submissionCount = Number(currentPreference?.payload?.submissionCount || 0); + if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`); + const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' })); + if (!choices.length) return sendError(response, 400, '请至少选择一个志愿'); + const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator'); + const generalChoices = choices.filter(item => item.preferenceType === 'general'); + if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`); + if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏'); + if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报'); + const plans = approvedPlans(db, setting.examId); + const indicator = indicatorQualification(db, setting.examId, user.id); + const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => { + if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false; + const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn'); + if (choice.preferenceType === 'indicator') { + const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId); + const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length; + return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used; + } + const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0); + return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length; + }))); + if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别'); 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 } }); + const preference = currentPreference || { 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, submissionCount: submissionCount + 1 } }); await database.saveAdmissionRecord(preference); - return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' }); + return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' }); } const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/); if (request.method === 'POST' && scoreAppealMatch) { diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index 21183f2..0424018 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -1,5 +1,5 @@ import { noticeForClient } from '../security/notice-content.mjs'; -import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs'; +import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; export function createPublicRoutes(context) { const { @@ -58,8 +58,20 @@ 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 })); - 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 && item.isSourceSchool), 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 { 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 && item.isSourceSchool), 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 } }; + }); + return sendJson(response, 200, payload); + } + if (pathname === '/api/public/announcements') { + const payload = await cache.remember('public', 'admission-announcements', async () => { + const db = await readDb(); + const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({ + id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, + rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' })) + })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); + const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); + const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); + return { ok: true, qualifications, admissions, cutoffs }; }); return sendJson(response, 200, payload); } diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs index da2dbb1..18d8359 100644 --- a/src/services/volunteer-admission.mjs +++ b/src/services/volunteer-admission.mjs @@ -20,6 +20,33 @@ 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 indicatorQualification(db, examId, userId) { + return admissionRecords(db, 'indicator_qualification', examId).find(item => item.userId === userId) || null; +} + +export function sourceSchoolQualificationStatus(db, examId, schoolId) { + const profiles = db.candidateProfiles.filter(profile => profile.schoolId === schoolId && profile.profileCompleted && db.users.some(user => user.id === profile.userId && user.role === 'candidate' && user.active)); + const qualifications = admissionRecords(db, 'indicator_qualification', examId).filter(item => item.schoolId === schoolId && item.status === 'confirmed'); + const byUser = new Map(qualifications.map(item => [item.userId, item])); + const rows = profiles.map(profile => { + const account = db.users.find(item => item.id === profile.userId) || {}; + const specialty = resolveProfileSpecialty(profile); + const qualification = byUser.get(profile.userId) || null; + return { + userId: profile.userId, + registrationNumber: account.candidateNumber || '', + name: profile.name || account.displayName || '', + eligible: qualification?.payload?.eligible === true, + confirmed: Boolean(qualification), + confirmedAt: qualification?.payload?.confirmedAt || qualification?.updatedAt || '', + specialtyCategory: specialty.category, + specialtyType: specialty.type, + specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' + }; + }).sort((left, right) => left.registrationNumber.localeCompare(right.registrationNumber)); + return { total: rows.length, confirmed: rows.filter(item => item.confirmed).length, complete: rows.length > 0 && rows.every(item => item.confirmed), rows }; +} + export function approvedPlans(db, examId) { return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved'); } @@ -46,6 +73,33 @@ export function publicAdmissionRows(db, examId) { }).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber)); } +export function admissionCutoffRows(db, examId) { + const groups = new Map(); + for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) { + const key = categoryKey(placement.schoolId, placement.payload?.categoryCode); + const row = groups.get(key) || { + schoolId: placement.schoolId, + schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '', + categoryCode: placement.payload?.categoryCode || '', + categoryName: placement.payload?.categoryName || '', + admittedCount: 0, + planQuota: 0, + highestScore: null, + cutoffScore: null + }; + const score = Number(placement.payload?.totalScore || 0); + row.admittedCount += 1; + row.highestScore = row.highestScore == null ? score : Math.max(row.highestScore, score); + row.cutoffScore = row.cutoffScore == null ? score : Math.min(row.cutoffScore, score); + groups.set(key, row); + } + for (const plan of approvedPlans(db, examId)) for (const category of plan.payload?.categories || []) { + const row = groups.get(categoryKey(plan.schoolId, category.code)); + if (row) row.planQuota = Number(category.quota || 0); + } + return [...groups.values()].sort((left, right) => left.schoolName.localeCompare(right.schoolName) || left.categoryName.localeCompare(right.categoryName)); +} + function categoryKey(schoolId, code) { return `${schoolId}|${code}`; } @@ -89,16 +143,18 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { if (!candidateEligibleForCategory(candidate.profile, category)) 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) { + if (choice.preferenceType === 'indicator') { + const qualification = indicatorQualification(db, examId, candidate.preference.userId); + const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId); + if (!qualification?.payload?.eligible || !allocation) continue; 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) { + if (!quotaBucket) continue; + } else { 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'; @@ -125,4 +181,4 @@ export function remainingPlanQuota(db, plan) { return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) }; }); } -import { candidateEligibleForCategory, resolveProfileSpecialty } from '../data/specialty-types.mjs'; +import { candidateEligibleForCategory, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; diff --git a/styles.css b/styles.css index b44d729..bd10ebc 100644 --- a/styles.css +++ b/styles.css @@ -622,3 +622,21 @@ button:disabled { cursor: not-allowed; opacity: .5; } .result-panel .result-summary.qualified > span:last-child strong,.result-panel .result-summary.unqualified > span:last-child strong { color:var(--navy); }.result-panel .result-summary.qualified > span:nth-child(3) strong { color:#237358; }.result-panel .result-summary.unqualified > span:nth-child(3) strong { color:#a24f43; } @media (max-width:1000px) { .admission-category-fields { grid-template-columns:1fr 1fr; }.admission-category-fields > label:first-child { grid-column:1/-1; }.feature-score-console,.admission-export-bar { grid-template-columns:1fr; }.school-type-summary { grid-template-columns:repeat(2,1fr); } } @media (max-width:620px) { .admission-category-fields,.specialty-plan-fields,.indicator-allocation-row,.preference-choice-row,.school-role-selector > div,.feature-score-console form,.school-type-summary { grid-template-columns:1fr; }.admission-category-fields > label:first-child { grid-column:auto; }.preference-choice-list .preference-choice-row > b { justify-self:start; }.admission-builder-head,.indicator-allocation-head { align-items:flex-start; flex-direction:column; }.result-panel .result-summary { grid-template-columns:1fr; } } + +.qualification-ledger-intro { margin-bottom:22px; padding:30px 34px; border-radius:22px; color:#fff; background:linear-gradient(125deg,#17375f,#245783 68%,#287486); box-shadow:0 18px 40px rgba(23,55,95,.18); } +.qualification-ledger-intro span,.announcement-hero .overline { color:#a9d9df; font:700 12px/1.4 Consolas,monospace; letter-spacing:.16em; } +.qualification-ledger-intro h2 { margin:8px 0; font:700 28px/1.2 STKaiti,KaiTi,serif; }.qualification-ledger-intro p { max-width:780px; margin:0; color:rgba(255,255,255,.78); } +.qualification-ledger { margin-bottom:22px; overflow:hidden; }.qualification-ledger > header { display:flex; align-items:center; justify-content:space-between; padding:22px 24px 14px; }.qualification-ledger > header span { color:#287486; font:700 12px Consolas,monospace; }.qualification-ledger > header h2 { margin:5px 0 0; } +.qualification-ledger > header > strong { padding:8px 13px; border-radius:999px; color:#9a6818; background:#fff6df; }.qualification-ledger > header > strong.complete { color:#17665b; background:#e5f5f1; } +.qualification-progress { height:4px; margin:0 24px; overflow:hidden; border-radius:99px; background:#e6ebf1; }.qualification-progress i { display:block; height:100%; background:linear-gradient(90deg,#287486,#5ea8a0); } +.qualification-publication-state { margin:14px 24px; padding:10px 13px; border-left:3px solid #287486; color:#17665b; background:#eef8f7; }.qualification-publication-state.pending { border-color:#c69037; color:#76551d; background:#fff8e9; }.qualification-ledger select { min-width:190px; } +.preference-choice-row.indicator { border-color:#d5c38f; background:#fffaf0; }.preference-choice-row.indicator > b { color:#8a641d; } + +.disclosure-entry { display:flex; align-items:center; justify-content:space-between; gap:32px; padding:34px 40px; border-radius:24px; color:#fff; background:#17375f; }.disclosure-entry h2 { margin:5px 0 10px; font:700 32px/1.15 STKaiti,KaiTi,serif; }.disclosure-entry p:last-child { margin:0; color:rgba(255,255,255,.72); } +.announcement-page { padding-bottom:70px; background:#f4f7fa; }.announcement-hero { min-height:360px; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:60px; padding:80px max(5vw,24px) 55px; color:#fff; background:radial-gradient(circle at 75% 15%,rgba(73,151,154,.38),transparent 32%),linear-gradient(132deg,#102a49,#17375f 55%,#245783); } +.announcement-hero h1 { margin:12px 0 18px; font:700 clamp(42px,6vw,74px)/.95 STKaiti,KaiTi,serif; letter-spacing:-.03em; }.announcement-hero h1 em { color:#9bd0d2; font-style:normal; }.announcement-hero p:last-child { max-width:700px; color:rgba(255,255,255,.75); font-size:16px; line-height:1.8; } +.announcement-hero dl { display:grid; grid-template-columns:repeat(3,110px); margin:0; border:1px solid rgba(255,255,255,.2); }.announcement-hero dl div { padding:20px; border-right:1px solid rgba(255,255,255,.2); }.announcement-hero dl div:last-child { border:0; }.announcement-hero dt { color:rgba(255,255,255,.6); font-size:12px; }.announcement-hero dd { margin:7px 0 0; font:700 30px Georgia,serif; } +.announcement-index { position:sticky; top:72px; z-index:5; display:flex; justify-content:center; gap:4px; padding:12px; border-bottom:1px solid #dfe5ec; background:rgba(247,249,252,.94); backdrop-filter:blur(12px); }.announcement-index a { padding:10px 18px; border-radius:999px; color:#17375f; font-weight:700; text-decoration:none; }.announcement-index a:hover { color:#287486; background:#e3eef2; } +.announcement-register { scroll-margin-top:130px; }.announcement-sheet { margin-top:20px; overflow:hidden; }.announcement-sheet > header { display:flex; align-items:center; justify-content:space-between; padding:22px 25px; border-bottom:1px solid #e6ebf1; background:linear-gradient(90deg,#fff,#f4f8fa); }.announcement-sheet > header span { color:#637386; font-size:13px; }.announcement-sheet > header h3 { margin:5px 0 0; font-size:20px; }.announcement-sheet > header > strong { color:#17375f; } +.qualification-result { display:inline-flex; min-width:38px; justify-content:center; padding:4px 9px; border-radius:99px; color:#7b5b24; background:#fff5db; font-weight:700; }.qualification-result.eligible { color:#17665b; background:#e4f4ef; }.cutoff-score { color:#a75c18; font:700 20px Georgia,serif; } +@media (max-width:760px) { .disclosure-entry,.announcement-hero { display:block; }.disclosure-entry button { margin-top:22px; }.announcement-hero dl { margin-top:28px; grid-template-columns:repeat(3,1fr); }.announcement-hero dl div { padding:14px 10px; }.announcement-index { justify-content:flex-start; overflow-x:auto; }.announcement-index a { white-space:nowrap; } } diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs index 4fad4e8..2d0c176 100644 --- a/tests/admission.test.mjs +++ b/tests/admission.test.mjs @@ -1,19 +1,19 @@ import assert from 'node:assert/strict'; -import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs'; +import { admissionCutoffRows, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs'; import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.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: '特长考生' } + { id: 'u-high', role: 'candidate', active: true, candidateNumber: '20260001', displayName: '高分考生' }, + { id: 'u-low', role: 'candidate', active: true, candidateNumber: '20260002', displayName: '次高考生' }, + { id: 'u-sport', role: 'candidate', active: true, 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', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] } + { userId: 'u-high', name: '高分考生', schoolId: 'source-a', profileCompleted: true, idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] }, + { userId: 'u-low', name: '次高考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] }, + { userId: 'u-sport', name: '特长考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] } ], schools: [ { id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' }, @@ -35,9 +35,11 @@ const db = { { code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }, { code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', 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' }] } } + { id: 'qual-low', kind: 'indicator_qualification', examId: 'exam', userId: 'u-low', schoolId: 'source-b', status: 'confirmed', payload: { eligible: false } }, + { id: 'qual-sport', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } }, + { id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } }, + { id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } }, + { id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } } ] }; @@ -62,5 +64,10 @@ 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'), '重要身份信息必须脱敏'); +const cutoffs = admissionCutoffRows(db, 'exam'); +assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分'); +const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b'); +assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件'); +assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型'); console.log('志愿投档、指标名额与脱敏公示测试通过'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 5d2d6a7..1ec18ee 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -47,7 +47,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行'); assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建'); assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行'); -assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v19 结构并重建更旧或未完成的开发结构'); +assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19, 20\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v20 结构并重建更旧或未完成的开发结构'); assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理'); const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8'); assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器'); @@ -232,7 +232,7 @@ try { inspector.close(); assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); - assert.equal(schemaVersion, 19, '学校类型、特长资格与特征分应使用 v19 数据结构'); + assert.equal(schemaVersion, 20, '指标资格、资格公示与分数线公告应使用 v20 数据结构'); 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), '考试专属表应与该场考试的考生、准考信息和成绩数据一致'); @@ -577,13 +577,29 @@ try { assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); const exam = createExam.data.exam; - assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5 } })).response.status, 200, '超级管理员应能按考试启用志愿功能'); + assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能按考试启用志愿功能并设置填报次数'); const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [ { code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] }, { code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] } ] } }); assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划'); assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts'); + assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划'); + assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格'); + assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格'); + const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); + const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id); + assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生'); + for (const row of qualificationExam.qualificationStatus.rows) { + const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${row.userId}`, { method: 'PUT', body: { eligible: row.registrationNumber === candidateNumber } }); + assert.equal(confirmation.response.status, 200, `生源校应能逐人确认指标分配资格:${confirmation.data?.error || confirmation.data?.message || ''}\n${serverError}`); + } + const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); + assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示'); + const publicQualification = await anonymous.request('/api/public/announcements'); + const qualificationPublication = publicQualification.data.qualifications.find(item => item.examId === exam.id && item.schoolName === '海州市第一中学'); + assert.ok(qualificationPublication, '资格全部确认后应自动出现在独立公开公告接口'); + assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格'); const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } }); assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用'); const createdExamInspector = new DatabaseSync(testDb, { readOnly: true }); @@ -895,6 +911,18 @@ try { assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总'); assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格'); assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算'); + assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数'); + const candidateAdmissions = await candidate.request('/api/candidate/admissions'); + const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id); + assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格'); + assert.equal(candidateAdmission.maxSubmissions, 1, '考生页面应显示管理员设置的提交次数上限'); + const firstPreference = await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [ + { schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'indicator' }, + { schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' } + ] } }); + assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿'); + assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定'); + assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改'); const classResults = await classAdmin.request('/api/admin/results'); assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩'); assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩'); From 0b423e565b1814f4ca7955289c83e9ab6919f427 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 15:07:19 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E8=B0=83=E4=BC=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 67 +++++++++++++++++++++++++++++++--- src/client/admin-views.mjs | 9 ++++- src/client/candidate-views.mjs | 7 ++-- src/client/public-views.mjs | 50 ++++++++++++++++++------- src/client/state.mjs | 2 + src/routes/admin.routes.mjs | 60 ++++++++++++++++++++++++++---- styles.css | 42 +++++++++++++++++++++ tests/system.test.mjs | 21 +++++++++-- 8 files changed, 222 insertions(+), 36 deletions(-) diff --git a/app.js b/app.js index 5a2d5f0..471d033 100644 --- a/app.js +++ b/app.js @@ -59,7 +59,7 @@ function renderError(error) { } const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; -const { brand, renderHome, renderAnnouncements, renderAuth } = createPublicViews(baseViewContext); +const { brand, renderHome, renderNoticeCenter, 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 }); @@ -75,7 +75,8 @@ async function renderRoute() { const route = location.hash.slice(1) || 'home'; const [section, page = 'dashboard'] = route.split('/'); if (section === 'home') renderHome(); - else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); } + else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); } + else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); } else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); @@ -103,6 +104,19 @@ function updateRegistrationSelection() { document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; }); } +function updateQualificationSelection(container) { + if (!container) return; + const rows = [...container.querySelectorAll('[data-qualification-select]')]; + const selected = rows.filter(input => input.checked); + const selectAll = container.querySelector('[data-action="qualification-select-all"]'); + if (selectAll) { + selectAll.checked = rows.length > 0 && selected.length === rows.length; + selectAll.indeterminate = selected.length > 0 && selected.length < rows.length; + } + const count = container.querySelector('[data-qualification-selected-count]'); + if (count) count.textContent = `已选 ${selected.length} 人`; +} + function applyTableFilters(tableId) { const table = document.getElementById(tableId); if (!table) return; @@ -182,9 +196,20 @@ document.addEventListener('click', async event => { await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; } if (action === 'open-notice') { - const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice; - const contentHtml = notice.contentHtml || `

${h(notice.content).replace(/\r?\n/g, '

')}

`; - setModal(`
${contentHtml}
`); return; + return navigate(`notice/${target.dataset.id}`); + } + if (action === 'notice-category') { + state.noticeCategory = target.dataset.category; + state.noticePage = 1; + renderNoticeCenter(state.publicAnnouncements); + return; + } + if (action === 'notice-page') { + if (target.disabled) return; + state.noticePage = Number(target.dataset.page || 1); + renderNoticeCenter(state.publicAnnouncements); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'download-admitted-candidates') { @@ -230,6 +255,37 @@ document.addEventListener('click', async event => { toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生'); return renderRoute(); } + if (action === 'qualification-select-all') { + const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger'); + container?.querySelectorAll('[data-qualification-select]').forEach(input => { input.checked = target.checked; }); + updateQualificationSelection(container); + return; + } + if (action === 'bulk-indicator-qualification') { + const toolbar = target.closest('[data-qualification-bulk]'); + const ledger = toolbar?.closest('.qualification-ledger'); + const userIds = [...ledger.querySelectorAll('[data-qualification-select]:checked')].map(input => input.closest('tr').dataset.userId); + const value = toolbar.querySelector('[data-bulk-eligible]').value; + if (!userIds.length) return toast('请先选择考生', '可使用左侧复选框或全选'); + if (!value) return toast('请选择批量资格结论'); + if (!window.confirm(`确认将所选 ${userIds.length} 名考生批量设为“${value === 'true' ? '有' : '无'}指标分配资格”?`)) return; + const data = await api(`/api/admin/indicator-qualifications/${toolbar.dataset.examId}/bulk`, { method: 'PUT', body: { userIds, eligible: value === 'true' } }); + toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`); + return renderRoute(); + } + if (action === 'toggle-admin-account') { + const active = target.dataset.active === 'true'; + if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return; + await api(`/api/admin/admins/${target.dataset.id}`, { method: 'PATCH', body: { active } }); + toast(active ? '管理员账户已启用' : '管理员账户已停用', active ? '该账号可以重新登录' : '现有会话已结束,历史记录仍保留'); + return renderRoute(); + } + if (action === 'reset-admin-password') { + if (!window.confirm('确认重置该管理员密码?其现有登录会话会立即失效。')) return; + const data = await api(`/api/admin/admins/${target.dataset.id}/reset-password`, { method: 'POST' }); + setModal(`
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}

旧密码和现有登录会话均已失效。

`); + return; + } if (action === 'add-admission-category') { const sources = state.pageData?.sourceSchools || []; target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources)); @@ -451,6 +507,7 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger')); if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target); if (event.target.matches('[data-registration-select]')) updateRegistrationSelection(); if (event.target.matches('[data-registration-select-all]')) { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index da5b702..8055568 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -177,7 +177,12 @@ export function createAdminViews(context) { function adminIndicatorQualifications(data) { if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。'); - return `
SOURCE SCHOOL CERTIFICATION

${h(data.school?.name)}资格确认簿

确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。

${data.exams.map(item => { const status = item.qualificationStatus; return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}
${status.rows.map(row => ``).join('') || ''}
报名号 / 姓名特长类型指标分配资格确认时间保存
${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}
本校暂无资料已完善的在册考生
`; }).join('')}`; + return `
SOURCE SCHOOL CERTIFICATION

${h(data.school?.name)}资格确认簿

逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。

${data.exams.map(item => { + const status = item.qualificationStatus; + const bulk = `
已选 0 人
`; + const rows = status.rows.map(row => `${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}`).join(''); + return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}${bulk}
${rows || ''}
选择报名号 / 姓名特长类型指标分配资格确认时间保存
本校暂无资料已完善的在册考生
`; + }).join('')}`; } function adminNotices(notices) { @@ -232,7 +237,7 @@ export function createAdminViews(context) { } function adminUsers(data) { - return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}
`; } function adminCenters(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index b788c0c..d66ba72 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -28,10 +28,9 @@ export function createCandidateViews(context) { const level = state.user?.adminLevel || 'super'; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; const security = ['security', '账户安全', 'user']; - 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], ['indicator-qualifications', '指标资格确认', 'check'], 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'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; + if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security]; + return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security]; } function portalShell(role, page, content, title, description) { diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 57a77f4..e5be14e 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -20,7 +20,7 @@ export function createPublicViews(context) { } function publicHeader() { - return `
${brand()}
`; + return `
${brand()}
`; } function renderHome() { @@ -32,27 +32,49 @@ export function createPublicViews(context) { app.innerHTML = `${publicHeader()}
-
最新

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
+
最新

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}
-

NOTICE BOARD

通知公告

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

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
-

ADMISSION DISCLOSURE

招生录取公开卷宗

指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。

+

NOTICE BOARD

通知公告

招生录取公示已纳入通知公告,可按类别统一查询。

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

OPEN EXAMINATIONS

考试报名

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

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

SERVICE FLOW

报名号是唯一账户

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

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

${item[1]}

${item[2]}

`).join('')}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

${organization.address || organization.email ? `

${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}

` : ''}
${h(siteCopy.footerNotice || '')}
`; } - function renderAnnouncements(data = state.publicAnnouncements) { + function noticeDocuments(data = state.publicAnnouncements) { + const ordinary = (state.publicData.notices || []).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt })); + const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` })); + const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: '录取名单', title: `${item.examName}最终录取名单`, summary: `共 ${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` })); + const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` })); + return [...ordinary, ...qualifications, ...admissions, ...cutoffs].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); + } + + function renderDocumentBody(document) { + if (document.documentType === 'notice') return `
${document.contentHtml || `

${h(document.content || '').replace(/\r?\n/g, '

')}

`}
`; + if (document.documentType === 'qualification') return `

本公示由生源校完成全部考生资格确认后自动生成。

${document.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`; + if (document.documentType === 'admission') return `

报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${document.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`; + return `

录取分数线为对应学校、招生类别最终录取考生的最低总成绩。

${document.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`; + } + + function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') { app.classList.remove('admin-readable'); + const documents = noticeDocuments(data); + const selected = documents.find(item => item.documentId === selectedId); const organization = state.publicData.organization || {}; - const sections = [data.qualifications?.length, data.admissions?.length, data.cutoffs?.length].filter(Boolean).length; - app.innerHTML = `${publicHeader()}

PUBLIC ADMISSION LEDGER

招生录取
公开卷宗

按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。

公开类别
${sections}
资格公示
${h(data.qualifications?.length || 0)}
录取公告
${h(data.admissions?.length || 0)}
- -

QUALIFICATION REGISTER

指标分配资格公示

仅在生源校全部考生完成资格确认后自动发布。

${(data.qualifications || []).map(item => `
${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}

${h(item.examName)}

${h(item.rows.length)} 人
${item.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`).join('') || '
暂无已完成全校确认的资格公示
'}
-

ADMISSION RESULTS

最终录取名单

录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。

${(data.admissions || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 人录取
${item.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`).join('') || '
暂无已完成的录取公告
'}
-

ADMISSION CUTOFFS

录取分数线统计

分数线为对应学校、招生类别最终录取考生的最低总成绩。

${(data.cutoffs || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 条分数线
${item.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`).join('') || '
暂无已发布的录取分数线
'}
-
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

招生公示数据由系统按业务状态自动生成
`; + if (selected) { + app.innerHTML = `${publicHeader()}
/${h(selected.subtype)}
${h(selected.category)} · ${h(selected.subtype)}

${h(selected.title)}

${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}

${renderDocumentBody(selected)}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

公开信息以本页面正式发布内容为准
`; + return; + } + const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))]; + const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部'; + const filtered = category === '全部' ? documents : documents.filter(item => item.category === category); + const pageSize = 8; + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1))); + state.noticeCategory = category; state.noticePage = page; + const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize); + app.innerHTML = `${publicHeader()}

PUBLIC NOTICE ARCHIVE

通知公告

考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。

${h(documents.length)}份公开文件
${h(category)}第 ${h(page)} / ${h(totalPages)} 页
共 ${h(filtered.length)} 条
${pageRows.map(item => ``).join('') || '
当前分类暂无公开信息
'}
${Array.from({length:totalPages},(_,index) => index + 1).map(value => ``).join('')}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

录取公示为通知公告中的公开类别
`; } function renderHeroTicket(exam) { @@ -61,7 +83,7 @@ export function createPublicViews(context) { } function renderNoticeRow(notice) { - return ``; + return ``; } function renderPublicExam(exam) { @@ -84,5 +106,5 @@ export function createPublicViews(context) { return `
`; } - return { brand, renderHome, renderAnnouncements, renderAuth }; + return { brand, renderHome, renderNoticeCenter, renderAuth }; } diff --git a/src/client/state.mjs b/src/client/state.mjs index f5a1a00..b14b346 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -3,6 +3,8 @@ export const state = { profile: null, publicData: { organization: {}, notices: [], exams: [], stats: {} }, publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] }, + noticeCategory: '全部', + noticePage: 1, permissions: [], scopeLabel: '', pageData: null, diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 44cf998..1675041 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -151,6 +151,37 @@ export function createAdminRoutes(context) { }).filter(item => item.exam); return sendJson(response, 200, { ok: true, school, exams }); } + const qualificationBulkMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/bulk$/); + if (qualificationBulkMatch && request.method === 'PUT') { + if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以批量确认指标分配资格'); + const setting = admissionSetting(db, qualificationBulkMatch[1]); + if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报'); + const body = await readJson(request); + if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择批量设置为有资格或无资格'); + const userIds = [...new Set((Array.isArray(body.userIds) ? body.userIds : []).map(value => cleanText(value, 64)).filter(Boolean))]; + if (!userIds.length) return sendError(response, 400, '请至少选择一名考生'); + const profiles = userIds.map(userId => db.candidateProfiles.find(item => item.userId === userId && item.schoolId === user.schoolId && item.profileCompleted)); + if (profiles.some(item => !item) || profiles.some(profile => !db.users.some(item => item.id === profile.userId && item.role === 'candidate' && item.active))) return sendError(response, 403, '批量名单中包含不属于本校的有效考生'); + const now = nowIso(); + const existing = new Map(admissionRecords(db, 'indicator_qualification', setting.examId).map(item => [item.userId, item])); + const qualifications = profiles.map(profile => { + const qualification = existing.get(profile.userId) || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now }; + Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } }); + return qualification; + }); + const replacementIds = new Set(qualifications.map(item => item.id)); + const nextDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !replacementIds.has(item.id)), ...qualifications] }; + const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId); + const records = [...qualifications]; + if (status.complete) { + const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId); + const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now }; + Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } }); + records.push(publication); + } + await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`)); + return sendJson(response, 200, { ok: true, count: qualifications.length, qualificationStatus: status, published: status.complete }); + } const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/); if (qualificationMatch && request.method === 'PUT') { if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格'); @@ -451,19 +482,34 @@ export function createAdminRoutes(context) { } const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/); if (adminMatch && request.method === 'PATCH') { - if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员'); + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能维护管理员'); const body = await readJson(request); - const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId); - if (!target) return sendError(response, 404, '班级管理员不存在'); - const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId); - if (!schoolClass) return sendError(response, 400, '请选择本校有效班级'); + const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))); + if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围'); + if (target.id === user.id && body.active === false) return sendError(response, 409, '不能停用当前正在使用的管理员账户'); + const schoolClass = target.adminLevel === 'class' ? db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === target.schoolId) : null; + if (target.adminLevel === 'class' && !schoolClass) return sendError(response, 400, '请选择该管理员所属学校的有效班级'); const password = String(body.password || ''); if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位'); - Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) }); + Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) }); if (password) target.passwordHash = hashPassword(password); - await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`)); + await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`)); + if (!target.active || password) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); return sendJson(response, 200, { ok: true, admin: safeUser(target) }); } + const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/); + if (adminPasswordResetMatch && request.method === 'POST') { + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能重置管理员密码'); + const target = db.users.find(item => item.id === adminPasswordResetMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))); + if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围'); + if (target.id === user.id) return sendError(response, 409, '当前账号请在“账户安全”中修改自己的密码'); + const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`; + target.passwordHash = hashPassword(temporaryPassword); + target.active = true; + await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`)); + for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword }); + } if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); diff --git a/styles.css b/styles.css index bd10ebc..5d3afb0 100644 --- a/styles.css +++ b/styles.css @@ -640,3 +640,45 @@ button:disabled { cursor: not-allowed; opacity: .5; } .announcement-register { scroll-margin-top:130px; }.announcement-sheet { margin-top:20px; overflow:hidden; }.announcement-sheet > header { display:flex; align-items:center; justify-content:space-between; padding:22px 25px; border-bottom:1px solid #e6ebf1; background:linear-gradient(90deg,#fff,#f4f8fa); }.announcement-sheet > header span { color:#637386; font-size:13px; }.announcement-sheet > header h3 { margin:5px 0 0; font-size:20px; }.announcement-sheet > header > strong { color:#17375f; } .qualification-result { display:inline-flex; min-width:38px; justify-content:center; padding:4px 9px; border-radius:99px; color:#7b5b24; background:#fff5db; font-weight:700; }.qualification-result.eligible { color:#17665b; background:#e4f4ef; }.cutoff-score { color:#a75c18; font:700 20px Georgia,serif; } @media (max-width:760px) { .disclosure-entry,.announcement-hero { display:block; }.disclosure-entry button { margin-top:22px; }.announcement-hero dl { margin-top:28px; grid-template-columns:repeat(3,1fr); }.announcement-hero dl div { padding:14px 10px; }.announcement-index { justify-content:flex-start; overflow-x:auto; }.announcement-index a { white-space:nowrap; } } + +/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */ +.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; } +.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); } +.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; } +.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; } +.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:22px 24px 24px; gap:17px; } +.admission-settings-panel form label,.admission-account-panel form label,.admission-plan-console form > label { display:grid; align-content:start; gap:7px; min-width:0; color:#45546a; font-size:13px; font-weight:700; } +.admission-settings-panel input,.admission-settings-panel select,.admission-account-panel input,.admission-account-panel select,.admission-plan-console form > label input,.admission-plan-console form > label select,.admission-plan-console form > label textarea { width:100%; min-height:44px; padding:9px 12px; border:1px solid #ccd6e2; border-radius:8px; color:#26364b; background:#fff; font-size:14px; } +.admission-settings-panel input:focus,.admission-settings-panel select:focus,.admission-account-panel input:focus,.admission-account-panel select:focus,.admission-plan-console input:focus,.admission-plan-console select:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); } +.admission-settings-panel label small { color:#7d899a; font-size:12px; font-weight:400; line-height:1.5; } +.admission-settings-panel .field-row,.admission-account-panel .field-row,.admission-plan-console > form > .field-row { gap:16px; } +.admission-settings-panel .agreement,.admission-account-panel .agreement { display:flex; align-items:center; gap:10px; font-weight:500; } +.admission-settings-panel .agreement input,.admission-account-panel .agreement input { width:18px; min-height:18px; } +.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { justify-self:end; min-width:190px; min-height:44px; } +.admission-control-actions { margin:0; padding:17px 24px; border-top:1px solid #e1e7ee; background:#f8fafc; } +.admission-control-actions button { min-height:42px; } +.admission-plan-console .admission-builder-head { margin-bottom:2px; }.admission-plan-console .admission-builder-head strong { font-size:16px; }.admission-plan-console .admission-builder-head small { font-size:12px; } +.admission-plan-console .admission-category-fields label > span,.admission-plan-console .indicator-allocation-row label span { font-size:12px; }.admission-plan-console .admission-category-fields input,.admission-plan-console .admission-category-fields select,.admission-plan-console .indicator-allocation-row input,.admission-plan-console .indicator-allocation-row select { min-height:43px; font-size:14px; } + +/* 指标资格批处理 */ +.qualification-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:16px 24px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; } +.qualification-bulk-toolbar > label { display:flex; align-items:center; gap:9px; color:#3d4e63; font-size:13px; font-weight:700; }.qualification-bulk-toolbar input[type="checkbox"] { width:17px; height:17px; } +.qualification-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.qualification-bulk-toolbar strong { margin-right:4px; color:#287486; font-size:13px; }.qualification-bulk-toolbar select { min-width:210px; min-height:40px; padding:7px 10px; border:1px solid #cbd6df; border-radius:7px; background:#fff; }.qualification-bulk-toolbar .solid-button { min-height:40px; padding:8px 14px; } +.qualification-ledger tbody td:first-child { width:52px; text-align:center; }.qualification-ledger [data-qualification-select] { width:17px; height:17px; } + +/* 管理员账户操作 */ +.admin-account-actions { display:flex; flex-wrap:wrap; gap:7px; }.row-action.danger { color:#a1463e; border-color:#e3bcb8; background:#fff8f7; }.row-action.danger:hover { color:#fff; background:#a94e45; }.admin-account-ledger td:last-child { min-width:180px; } + +/* 完整通知目录与正文页 */ +.notice-archive-link { grid-column:1/-1; justify-self:end; border:0; color:#245783; background:transparent; font-weight:700; cursor:pointer; } +.notice-center-page,.notice-document-page { min-height:calc(100vh - 76px); padding-bottom:80px; background:#f3f6f9; } +.notice-center-hero { display:flex; align-items:flex-end; justify-content:space-between; min-height:255px; padding:70px max(5vw,28px) 42px; color:#fff; background:linear-gradient(118deg,#132f52 0 62%,#20677a); } +.notice-center-hero h1 { margin:8px 0 12px; font:400 clamp(40px,5vw,62px)/1 STKaiti,KaiTi,serif; }.notice-center-hero p:last-child { margin:0; color:rgba(255,255,255,.72); font-size:15px; }.notice-center-hero > strong { display:grid; justify-items:end; font:400 52px/1 Georgia,serif; }.notice-center-hero > strong small { margin-top:8px; color:#a9d9df; font:12px/1.2 system-ui,sans-serif; letter-spacing:.12em; } +.notice-center-shell { width:min(1180px,calc(100% - 48px)); display:grid; grid-template-columns:210px minmax(0,1fr); gap:0; margin:38px auto 0; border:1px solid #dbe2ea; background:#fff; box-shadow:0 16px 44px rgba(22,46,75,.08); } +.notice-category-nav { padding:24px 0; border-right:1px solid #e1e6ec; background:#f7f9fb; }.notice-category-nav button { width:100%; display:flex; justify-content:space-between; padding:13px 20px; border:0; border-left:3px solid transparent; color:#5f6d80; background:transparent; text-align:left; cursor:pointer; }.notice-category-nav button span { color:#9aa4b2; }.notice-category-nav button.active { border-left-color:#287486; color:#17375f; background:#eaf2f4; font-weight:700; } +.notice-directory > header { display:flex; align-items:center; justify-content:space-between; min-height:72px; padding:0 28px; border-bottom:1px solid #e1e6ec; }.notice-directory > header div { display:flex; align-items:baseline; gap:13px; }.notice-directory > header strong { color:#17375f; font-size:18px; }.notice-directory > header span,.notice-directory > header small { color:#8894a4; font-size:12px; } +.notice-directory-list > button { width:100%; display:grid; grid-template-columns:76px minmax(0,1fr) 24px; align-items:center; gap:20px; min-height:112px; padding:18px 28px; border:0; border-bottom:1px solid #e6eaf0; color:inherit; background:#fff; text-align:left; cursor:pointer; transition:background .16s,padding-left .16s; }.notice-directory-list > button:hover { padding-left:34px; background:#f7fafb; }.notice-directory-list time { display:grid; justify-items:center; padding-right:18px; border-right:1px solid #dce3e9; color:#8190a2; }.notice-directory-list time strong { color:#245783; font:400 27px Georgia,serif; }.notice-directory-list time span { margin-top:5px; font-size:11px; } +.notice-directory-copy { display:grid; gap:6px; min-width:0; }.notice-directory-copy em { color:#287486; font-size:11px; font-style:normal; font-weight:700; letter-spacing:.08em; }.notice-directory-copy strong { color:#24354b; font-size:17px; }.notice-directory-copy small { overflow:hidden; color:#7d8998; font-size:13px; line-height:1.55; text-overflow:ellipsis; white-space:nowrap; }.notice-directory-arrow { color:#8092a6; } +.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; } +.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; } +@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 1ec18ee..e3a5d33 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -396,6 +396,16 @@ try { const adminDirectory = await admin.request('/api/admin/admins'); assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员'); + const managedAdmin = adminDirectory.data.admins.find(item => item.username === 'school_admin_2'); + assert.ok(managedAdmin, '管理员台账应返回可管理的校级管理员'); + assert.equal((await admin.request(`/api/admin/admins/${adminDirectory.data.admins.find(item => item.username === 'admin')?.id}`, { method: 'PATCH', body: { active: false } })).response.status, 409, '超级管理员不能停用当前正在使用的自己'); + assert.equal((await admin.request(`/api/admin/admins/${managedAdmin.id}`, { method: 'PATCH', body: { active: false } })).response.status, 200, '超级管理员应能停用其他管理员账户'); + assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, false, '管理员台账应同步显示停用状态'); + const adminPasswordReset = await admin.request(`/api/admin/admins/${managedAdmin.id}/reset-password`, { method: 'POST' }); + assert.equal(adminPasswordReset.response.status, 200, '超级管理员应能重置其他管理员密码'); + assert.match(adminPasswordReset.data.temporaryPassword, /^Reset-/, '管理员密码重置应返回一次性临时密码'); + assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, true, '重置密码应重新启用目标管理员账户'); + assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: managedAdmin.username, password: adminPasswordReset.data.temporaryPassword } })).response.status, 200, '被重置的管理员应可使用临时密码重新登录'); const schoolCenters = await schoolAdmin.request('/api/admin/centers'); assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点'); const legacySuperCenters = await legacyAdmin.request('/api/admin/centers'); @@ -590,10 +600,13 @@ try { const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id); assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生'); - for (const row of qualificationExam.qualificationStatus.rows) { - const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${row.userId}`, { method: 'PUT', body: { eligible: row.registrationNumber === candidateNumber } }); - assert.equal(confirmation.response.status, 200, `生源校应能逐人确认指标分配资格:${confirmation.data?.error || confirmation.data?.message || ''}\n${serverError}`); - } + const bulkQualification = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/bulk`, { method: 'PUT', body: { userIds: qualificationExam.qualificationStatus.rows.map(row => row.userId), eligible: false } }); + assert.equal(bulkQualification.response.status, 200, '生源校应能多选后一键批量确认指标分配资格'); + assert.equal(bulkQualification.data.count, qualificationExam.qualificationStatus.rows.length, '批量确认应返回实际更新人数'); + assert.equal(bulkQualification.data.published, true, '批量确认覆盖全校考生时应自动完成公示'); + const candidateQualificationRow = qualificationExam.qualificationStatus.rows.find(row => row.registrationNumber === candidateNumber); + const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${candidateQualificationRow.userId}`, { method: 'PUT', body: { eligible: true } }); + assert.equal(confirmation.response.status, 200, '批量确认后仍应支持逐人修正资格'); const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示'); const publicQualification = await anonymous.request('/api/public/announcements');