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