Add admission function #1

Merged
biss merged 5 commits from Add-Admission-Function into master 2026-07-21 15:11:56 +08:00
24 changed files with 770 additions and 30 deletions
Showing only changes of commit ae528b20e9 - Show all commits

No files matched your search

+15
View File
@@ -256,6 +256,21 @@ npm run seed-test-data:mysql -- --force
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。 强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。
## 中考志愿填报与招生录取
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。
数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
## 手动测试数据账号 ## 手动测试数据账号
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建: 测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
+59
View File
@@ -1,6 +1,7 @@
import { api } from './src/client/api.mjs'; import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs'; import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs'; import { createCandidateViews } from './src/client/candidate-views.mjs';
import { createAdmissionViews } from './src/client/admission-views.mjs';
import { createPublicViews } from './src/client/public-views.mjs'; import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs'; import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs'; import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
@@ -59,6 +60,7 @@ const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, pa
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
function navigate(route) { function navigate(route) {
location.hash = route; location.hash = route;
@@ -74,6 +76,7 @@ async function renderRoute() {
else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page); else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page); else if (section === 'admin') await renderAdmin(page);
else if (section === 'admission_school') await renderAdmission(page);
else navigate('home'); else navigate('home');
} }
@@ -181,6 +184,35 @@ document.addEventListener('click', async event => {
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return; setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
} }
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
if (action === 'admission-plan-review') {
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
if (reviewNote == null) return;
await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute();
}
if (action === 'admission-match') {
if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' });
toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute();
}
if (action === 'admission-finalize') {
if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return;
const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' });
await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute();
}
if (action === 'admission-supplementary') {
const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00)', '');
if (!preferenceEnd) return;
await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } });
toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute();
}
if (action === 'withdrawal-review') {
const approved = target.dataset.approved === 'true';
const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', '');
if (reviewNote == null) return;
await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
}
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) { if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
event.preventDefault(); event.preventDefault();
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value; const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
@@ -527,6 +559,12 @@ document.addEventListener('submit', async event => {
} else if (kind === 'candidate-profile') { } else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
} else if (kind === 'volunteer-preference') {
const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => {
const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode };
});
await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
} else if (kind === 'exam-registration') { } else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -548,6 +586,27 @@ document.addEventListener('submit', async event => {
const body = formObject(form); const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body }); await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
} else if (kind === 'admission-setting') {
const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5);
await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body });
toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute();
} else if (kind === 'admission-account') {
await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
form.reset(); toast('招生学校账号已创建'); renderRoute();
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
const body = formObject(form);
body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => {
const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim());
const indicatorAllocations = indicators.split(/[,,]/).map(entry => { const [sourceSchoolId, count] = entry.split(':').map(item => item.trim()); return { sourceSchoolId, quota: Number(count) }; }).filter(item => item.sourceSchoolId && item.quota > 0);
return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations };
}).filter(item => item.name && item.quota > 0);
if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划');
await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body });
toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
} else if (kind === 'placement-review') {
const body = formObject(form);
await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
} else if (kind === 'school-form') { } else if (kind === 'school-form') {
const body = formObject(form); body.active = form.active.checked; const body = formObject(form); body.active = form.active.checked;
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body }); await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
+69 -3
View File
@@ -35,6 +35,7 @@ export const relationalTables = [
'workflow_steps', 'workflow_steps',
'workflow_instances', 'workflow_instances',
'workflow_actions', 'workflow_actions',
'admission_records',
'audit_logs' 'audit_logs'
]; ];
@@ -44,7 +45,7 @@ function validateState(state, source = '数据库') {
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
'admissionNumberRules', 'arrangementPlans', 'admissionNumberRules', 'arrangementPlans',
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' 'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs'
]; ];
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
throw new Error(`${source}中的应用数据格式无效`); throw new Error(`${source}中的应用数据格式无效`);
@@ -59,7 +60,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( add(
'UPDATE schema_metadata SET schema_version = 17, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 18, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() state.meta?.createdAt || new Date().toISOString()
); );
@@ -335,6 +336,16 @@ export function buildSeedOperations(state) {
); );
} }
for (const record of state.admissionRecords) {
add(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, nullable(record.userId), nullable(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
);
}
for (const log of state.auditLogs) { for (const log of state.auditLogs) {
add( add(
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
@@ -507,6 +518,9 @@ function stateFromRows(rows) {
postalCode: row.postal_code || '', postalCode: row.postal_code || '',
guardianName: row.guardian_name || '', guardianName: row.guardian_name || '',
guardianPhone: row.guardian_phone || '', guardianPhone: row.guardian_phone || '',
specialtyTypes: (() => { try { return JSON.parse(row.specialty_types || '[]'); } catch { return []; } })(),
specialtyCertificate: row.specialty_certificate || '',
policyEligibility: row.policy_eligibility || '',
profileCompleted: Boolean(row.profile_completed), profileCompleted: Boolean(row.profile_completed),
status: row.status, status: row.status,
reviewNote: row.review_note || '', reviewNote: row.review_note || '',
@@ -738,6 +752,17 @@ function stateFromRows(rows) {
toAssigneeId: row.to_assignee_id, toAssigneeId: row.to_assignee_id,
createdAt: row.created_at createdAt: row.created_at
})), })),
admissionRecords: rows.admissionRecords.map(row => ({
id: row.id,
kind: row.kind,
examId: row.exam_id,
userId: row.user_id || null,
schoolId: row.school_id || null,
status: row.status,
payload: (() => { try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; } })(),
createdAt: row.created_at,
updatedAt: row.updated_at
})),
auditLogs: rows.auditLogs.map(row => ({ auditLogs: rows.auditLogs.map(row => ({
id: row.id, id: row.id,
actorId: row.actor_id, actorId: row.actor_id,
@@ -779,6 +804,7 @@ function readSqliteRows(connection) {
workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(), workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(),
workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(), workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(),
workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(), workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(),
admissionRecords: connection.prepare('SELECT * FROM admission_records ORDER BY created_at, id').all(),
auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all() auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all()
}; };
} }
@@ -815,6 +841,7 @@ async function readMysqlRows(connection) {
workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'), workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'),
workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'), workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'),
workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'), workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'),
admissionRecords: await query('SELECT * FROM admission_records ORDER BY created_at, id'),
auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC') auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC')
}; };
} }
@@ -925,6 +952,7 @@ function createRepository({ client, location, read, transaction, close }) {
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?, native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ? profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
@@ -933,7 +961,8 @@ function createRepository({ client, location, read, transaction, close }) {
optional(profile.address), optional(profile.schoolId), optional(profile.address), optional(profile.schoolId),
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), JSON.stringify(profile.specialtyTypes || []),
optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
optional(profile.reviewerId), profile.updatedAt, profile.id optional(profile.reviewerId), profile.updatedAt, profile.id
), ),
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId)
@@ -1186,6 +1215,43 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) auditOperation(log)
]); ]);
}, },
async createAdmissionSchoolAccount(user, log) {
await transaction([
operation(
`INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (?, ?, ?, 'admission_school', NULL, ?, NULL, ?, ?, ?)`,
user.id, user.username, user.passwordHash, user.schoolId, user.active === false ? 0 : 1, user.displayName, user.createdAt
),
auditOperation(log)
]);
},
async saveAdmissionRecord(record, log = null) {
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
)];
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async saveAdmissionRecords(records, log = null) {
const operations = [];
for (const record of records) {
operations.push(operation('DELETE FROM admission_records WHERE id = ?', record.id));
operations.push(operation(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
));
}
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async saveSchool(school, isNew, log) { async saveSchool(school, isNew, log) {
const change = isNew const change = isNew
? operation( ? operation(
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff. Show more