准考证编排
This commit is contained in:
@@ -48,7 +48,10 @@
|
|||||||
- 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分
|
- 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分
|
||||||
- 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略
|
- 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略
|
||||||
- 通知发布、草稿、撤回及首页置顶
|
- 通知发布、草稿、撤回及首页置顶
|
||||||
- 为审核通过的报名生成准考证号、考点、考场和座位
|
- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编
|
||||||
|
- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则
|
||||||
|
- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排
|
||||||
|
- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现
|
||||||
- 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果
|
- 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果
|
||||||
- 管理员与考生接口权限隔离
|
- 管理员与考生接口权限隔离
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v8,不兼容旧数据;升级本分支后请运行 `npm run reset-db` 重建开发库。v8 增加班级审批节点、成绩复议流程、严格校班匹配和多管理员均分。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v9,不兼容旧准考证编排数据;启动时会替换旧准考证相关表,也可运行 `npm run reset-db` 重建完整开发库。v9 增加整场编排方案、五级混编、可扩展号码规则和逐科考场座位。
|
||||||
|
|
||||||
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
@@ -109,7 +112,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex
|
|||||||
|
|
||||||
- `schools`、`school_classes`、`users`、`candidate_profiles`
|
- `schools`、`school_classes`、`users`、`candidate_profiles`
|
||||||
- `exams`、`exam_subjects`
|
- `exams`、`exam_subjects`
|
||||||
- `registrations`、`registration_subjects`、`admit_cards`
|
- `registrations`、`registration_subjects`
|
||||||
|
- `admission_number_rules`、`exam_arrangement_plans`、`admit_cards`、`admit_card_subjects`
|
||||||
- `results`、`notices`、`audit_logs`
|
- `results`、`notices`、`audit_logs`
|
||||||
- `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms`
|
- `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms`
|
||||||
- `number_rules`、`number_rule_segments`
|
- `number_rules`、`number_rule_segments`
|
||||||
@@ -151,7 +155,7 @@ npm start
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证、成绩复议、校班严格匹配和多人均分。
|
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -179,10 +179,23 @@ document.addEventListener('click', async event => {
|
|||||||
if (action === 'generate-admit') {
|
if (action === 'generate-admit') {
|
||||||
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
||||||
if (registration.admitCard) return openAdmitPreview(registration);
|
if (registration.admitCard) return openAdmitPreview(registration);
|
||||||
const testCenter = prompt('请输入考点名称:', '海州市第一中学');
|
return toast('尚未编排', '请使用页面上方的整场编排控制台');
|
||||||
if (!testCenter) return;
|
}
|
||||||
await api(`/api/admin/registrations/${target.dataset.id}/admit-card`, { method: 'POST', body: { testCenter } });
|
if (action === 'preview-arrangement') {
|
||||||
toast('准考证已生成', '考生可在开放时间内下载'); return renderRoute();
|
const form = target.closest('form');
|
||||||
|
const body = formObject(form);
|
||||||
|
const output = form.parentElement.querySelector('[data-arrangement-preview]');
|
||||||
|
target.disabled = true;
|
||||||
|
target.textContent = '正在预检…';
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement/preview`, { method: 'POST', body });
|
||||||
|
const summary = data.summary;
|
||||||
|
output.innerHTML = `<div class="arrangement-preview ${data.warnings.length ? 'warning' : 'ok'}"><strong>${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}</strong><span>${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合</span>${data.warnings.length ? `<ul>${data.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<small>多科目同考点、容量、科目时间和号码唯一性检查均已通过。</small>'}</div>`;
|
||||||
|
} finally {
|
||||||
|
target.disabled = false;
|
||||||
|
target.textContent = '仅预检,不写入';
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'toggle-exam') {
|
if (action === 'toggle-exam') {
|
||||||
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
||||||
@@ -367,6 +380,10 @@ document.addEventListener('submit', async event => {
|
|||||||
})).sort((a, b) => a.position - b.position);
|
})).sort((a, b) => a.position - b.position);
|
||||||
await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } });
|
await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } });
|
||||||
toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute();
|
toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute();
|
||||||
|
} else if (kind === 'admission-arrangement') {
|
||||||
|
const body = formObject(form);
|
||||||
|
const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement`, { method: 'POST', body });
|
||||||
|
toast('整场编排已生成', `${data.summary.candidateCount} 名考生 · ${data.summary.subjectAssignmentCount} 个科次座位`); renderRoute();
|
||||||
} else if (kind === 'workflow-design') {
|
} else if (kind === 'workflow-design') {
|
||||||
const names = [...form.querySelectorAll('[name="stepName"]')];
|
const names = [...form.querySelectorAll('[name="stepName"]')];
|
||||||
const levels = [...form.querySelectorAll('[name="stepLevel"]')];
|
const levels = [...form.querySelectorAll('[name="stepLevel"]')];
|
||||||
@@ -542,7 +559,12 @@ function openExamForm(exam = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAdmitPreview(reg) {
|
function openAdmitPreview(reg) {
|
||||||
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证已生成</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场</dt><dd>${h(reg.admitCard.room)}</dd></div><div><dt>座位号</dt><dd>${h(reg.admitCard.seat)}</dd></div><div><dt>生成时间</dt><dd>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><p>考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
|
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
|
||||||
|
const rows = reg.subjects.map(subject => {
|
||||||
|
const assignment = assignments.get(subject.id) || {};
|
||||||
|
return `<tr><td>${h(subject.name)}</td><td>${h(subject.date)} ${h(subject.start)}</td><td>${h(assignment.roomName || assignment.room || '待定')}</td><td>${h(assignment.seat || '—')}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证编排明细</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><dl><div><dt>固定考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>生成时间</dt><dd>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><table><thead><tr><th>科目</th><th>时间</th><th>考场</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><p>考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', renderRoute);
|
window.addEventListener('hashchange', renderRoute);
|
||||||
|
|||||||
+136
-21
@@ -16,7 +16,10 @@ export const relationalTables = [
|
|||||||
'exam_subjects',
|
'exam_subjects',
|
||||||
'registrations',
|
'registrations',
|
||||||
'registration_subjects',
|
'registration_subjects',
|
||||||
|
'admission_number_rules',
|
||||||
|
'exam_arrangement_plans',
|
||||||
'admit_cards',
|
'admit_cards',
|
||||||
|
'admit_card_subjects',
|
||||||
'results',
|
'results',
|
||||||
'test_centers',
|
'test_centers',
|
||||||
'test_rooms',
|
'test_rooms',
|
||||||
@@ -37,6 +40,7 @@ function validateState(state, source = '数据库') {
|
|||||||
const collections = [
|
const collections = [
|
||||||
'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results',
|
'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results',
|
||||||
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
|
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
|
||||||
|
'admissionNumberRules', 'arrangementPlans',
|
||||||
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
|
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
|
||||||
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
|
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
|
||||||
];
|
];
|
||||||
@@ -53,7 +57,7 @@ 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 = 8, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
'UPDATE schema_metadata SET schema_version = 9, 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()
|
||||||
);
|
);
|
||||||
@@ -150,15 +154,6 @@ function buildSeedOperations(state) {
|
|||||||
for (const subjectId of registration.subjectIds) {
|
for (const subjectId of registration.subjectIds) {
|
||||||
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
||||||
}
|
}
|
||||||
if (registration.admitCard) {
|
|
||||||
add(
|
|
||||||
`INSERT INTO admit_cards (
|
|
||||||
registration_id, card_number, test_center, room, seat, generated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
registration.id, registration.admitCard.number, registration.admitCard.testCenter,
|
|
||||||
registration.admitCard.room, registration.admitCard.seat, registration.admitCard.generatedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const result of state.results) {
|
for (const result of state.results) {
|
||||||
@@ -197,6 +192,46 @@ function buildSeedOperations(state) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const rule of state.admissionNumberRules) {
|
||||||
|
add(
|
||||||
|
`INSERT INTO admission_number_rules (
|
||||||
|
id, code, name, description, separator, segments_json, example, active, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
|
||||||
|
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plan of state.arrangementPlans) {
|
||||||
|
add(
|
||||||
|
`INSERT INTO exam_arrangement_plans (
|
||||||
|
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
|
||||||
|
subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json, generated_by, generated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed,
|
||||||
|
Number(plan.candidateCount || 0), Number(plan.centerCount || 0), Number(plan.subjectAssignmentCount || 0),
|
||||||
|
Number(plan.subjectCombinationCount || 0), Number(plan.sameSchoolCenterRate || 0), JSON.stringify(plan.warnings || []),
|
||||||
|
nullable(plan.generatedBy), plan.generatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const registration of state.registrations.filter(item => item.admitCard)) {
|
||||||
|
const card = registration.admitCard;
|
||||||
|
add(
|
||||||
|
`INSERT INTO admit_cards (
|
||||||
|
registration_id, plan_id, card_number, center_id, test_center, generated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, card.generatedAt
|
||||||
|
);
|
||||||
|
for (const assignment of card.assignments || []) add(
|
||||||
|
`INSERT INTO admit_card_subjects (
|
||||||
|
registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room,
|
||||||
|
assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const request of state.centerChangeRequests) {
|
for (const request of state.centerChangeRequests) {
|
||||||
add(
|
add(
|
||||||
`INSERT INTO center_change_requests (
|
`INSERT INTO center_change_requests (
|
||||||
@@ -328,13 +363,39 @@ function stateFromRows(rows) {
|
|||||||
subjectIds.push(row.subject_id);
|
subjectIds.push(row.subject_id);
|
||||||
registrationSubjects.set(row.registration_id, subjectIds);
|
registrationSubjects.set(row.registration_id, subjectIds);
|
||||||
}
|
}
|
||||||
const admitCards = new Map(rows.admitCards.map(row => [row.registration_id, {
|
const subjectPositions = new Map(rows.subjects.map(row => [row.id, Number(row.position)]));
|
||||||
number: row.card_number,
|
const admitAssignments = new Map();
|
||||||
testCenter: row.test_center,
|
for (const row of rows.admitCardSubjects) {
|
||||||
|
const assignments = admitAssignments.get(row.registration_id) || [];
|
||||||
|
assignments.push({
|
||||||
|
subjectId: row.subject_id,
|
||||||
|
roomId: row.room_id,
|
||||||
|
roomName: row.room,
|
||||||
room: row.room,
|
room: row.room,
|
||||||
|
roomCode: row.room_code,
|
||||||
|
examRoomCode: row.exam_room_code,
|
||||||
seat: row.seat,
|
seat: row.seat,
|
||||||
|
subjectSignature: row.subject_signature || ''
|
||||||
|
});
|
||||||
|
admitAssignments.set(row.registration_id, assignments);
|
||||||
|
}
|
||||||
|
for (const assignments of admitAssignments.values()) assignments.sort((left, right) =>
|
||||||
|
(subjectPositions.get(left.subjectId) || 0) - (subjectPositions.get(right.subjectId) || 0)
|
||||||
|
);
|
||||||
|
const admitCards = new Map(rows.admitCards.map(row => {
|
||||||
|
const assignments = admitAssignments.get(row.registration_id) || [];
|
||||||
|
const primary = assignments[0] || {};
|
||||||
|
return [row.registration_id, {
|
||||||
|
planId: row.plan_id,
|
||||||
|
number: row.card_number,
|
||||||
|
centerId: row.center_id,
|
||||||
|
testCenter: row.test_center,
|
||||||
|
room: primary.roomName || '',
|
||||||
|
seat: primary.seat || '',
|
||||||
|
assignments,
|
||||||
generatedAt: row.generated_at
|
generatedAt: row.generated_at
|
||||||
}]));
|
}];
|
||||||
|
}));
|
||||||
const ruleSegments = new Map();
|
const ruleSegments = new Map();
|
||||||
for (const row of rows.numberRuleSegments) {
|
for (const row of rows.numberRuleSegments) {
|
||||||
const segments = ruleSegments.get(row.rule_id) || [];
|
const segments = ruleSegments.get(row.rule_id) || [];
|
||||||
@@ -567,6 +628,32 @@ function stateFromRows(rows) {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
notes: row.notes || ''
|
notes: row.notes || ''
|
||||||
})),
|
})),
|
||||||
|
admissionNumberRules: rows.admissionNumberRules.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
separator: row.separator || '',
|
||||||
|
segments: Array.isArray(row.segments_json) ? row.segments_json : JSON.parse(row.segments_json || '[]'),
|
||||||
|
example: row.example || '',
|
||||||
|
active: Boolean(row.active),
|
||||||
|
createdAt: row.created_at
|
||||||
|
})),
|
||||||
|
arrangementPlans: rows.arrangementPlans.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
examId: row.exam_id,
|
||||||
|
numberRuleId: row.number_rule_id,
|
||||||
|
mixingScope: row.mixing_scope,
|
||||||
|
randomSeed: row.random_seed,
|
||||||
|
candidateCount: Number(row.candidate_count),
|
||||||
|
centerCount: Number(row.center_count),
|
||||||
|
subjectAssignmentCount: Number(row.subject_assignment_count),
|
||||||
|
subjectCombinationCount: Number(row.subject_combination_count),
|
||||||
|
sameSchoolCenterRate: Number(row.same_school_center_rate),
|
||||||
|
warnings: Array.isArray(row.warnings_json) ? row.warnings_json : JSON.parse(row.warnings_json || '[]'),
|
||||||
|
generatedBy: row.generated_by,
|
||||||
|
generatedAt: row.generated_at
|
||||||
|
})),
|
||||||
numberRules: rows.numberRules.map(row => ({
|
numberRules: rows.numberRules.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -649,7 +736,10 @@ function readSqliteRows(connection) {
|
|||||||
subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(),
|
subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(),
|
||||||
registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(),
|
registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(),
|
||||||
registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(),
|
registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(),
|
||||||
|
admissionNumberRules: connection.prepare('SELECT * FROM admission_number_rules ORDER BY created_at, id').all(),
|
||||||
|
arrangementPlans: connection.prepare('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id').all(),
|
||||||
admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(),
|
admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(),
|
||||||
|
admitCardSubjects: connection.prepare('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id').all(),
|
||||||
results: connection.prepare('SELECT * FROM results ORDER BY id').all(),
|
results: connection.prepare('SELECT * FROM results ORDER BY id').all(),
|
||||||
testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(),
|
testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(),
|
||||||
testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(),
|
testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(),
|
||||||
@@ -682,7 +772,10 @@ async function readMysqlRows(connection) {
|
|||||||
subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'),
|
subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'),
|
||||||
registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'),
|
registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'),
|
||||||
registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'),
|
registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'),
|
||||||
|
admissionNumberRules: await query('SELECT * FROM admission_number_rules ORDER BY created_at, id'),
|
||||||
|
arrangementPlans: await query('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id'),
|
||||||
admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'),
|
admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'),
|
||||||
|
admitCardSubjects: await query('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id'),
|
||||||
results: await query('SELECT * FROM results ORDER BY id'),
|
results: await query('SELECT * FROM results ORDER BY id'),
|
||||||
testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'),
|
testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'),
|
||||||
testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'),
|
testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'),
|
||||||
@@ -1154,16 +1247,38 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
operations.push(auditOperation(log));
|
operations.push(auditOperation(log));
|
||||||
await transaction(operations);
|
await transaction(operations);
|
||||||
},
|
},
|
||||||
async createAdmitCard(registrationId, admitCard, log) {
|
async saveAdmissionArrangement(plan, cards, log) {
|
||||||
await transaction([
|
const operations = [
|
||||||
|
operation('DELETE FROM exam_arrangement_plans WHERE exam_id = ?', plan.examId),
|
||||||
operation(
|
operation(
|
||||||
|
`INSERT INTO exam_arrangement_plans (
|
||||||
|
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
|
||||||
|
subject_assignment_count, subject_combination_count, same_school_center_rate,
|
||||||
|
warnings_json, generated_by, generated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed,
|
||||||
|
Number(plan.candidateCount), Number(plan.centerCount), Number(plan.subjectAssignmentCount),
|
||||||
|
Number(plan.subjectCombinationCount), Number(plan.sameSchoolCenterRate), JSON.stringify(plan.warnings || []),
|
||||||
|
optional(plan.generatedBy), plan.generatedAt
|
||||||
|
)
|
||||||
|
];
|
||||||
|
for (const card of cards) {
|
||||||
|
operations.push(operation(
|
||||||
`INSERT INTO admit_cards (
|
`INSERT INTO admit_cards (
|
||||||
registration_id, card_number, test_center, room, seat, generated_at
|
registration_id, plan_id, card_number, center_id, test_center, generated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
registrationId, admitCard.number, admitCard.testCenter, admitCard.room, admitCard.seat, admitCard.generatedAt
|
card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, card.generatedAt
|
||||||
),
|
));
|
||||||
auditOperation(log)
|
for (const assignment of card.assignments) operations.push(operation(
|
||||||
]);
|
`INSERT INTO admit_card_subjects (
|
||||||
|
registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName,
|
||||||
|
assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature
|
||||||
|
));
|
||||||
|
}
|
||||||
|
operations.push(auditOperation(log));
|
||||||
|
await transaction(operations);
|
||||||
},
|
},
|
||||||
async createExam(exam, log) {
|
async createExam(exam, log) {
|
||||||
const operations = [operation(
|
const operations = [operation(
|
||||||
|
|||||||
+6
-2
@@ -542,8 +542,12 @@ function escapeHtml(value) {
|
|||||||
function admitCardHtml(db, user, profile, registration) {
|
function admitCardHtml(db, user, profile, registration) {
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||||
const rows = subjects.map(subject => `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}—${escapeHtml(subject.end)}</td><td>${escapeHtml(registration.admitCard.room)}</td></tr>`).join('');
|
const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item]));
|
||||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(exam.name)}准考证</title><style>body{font-family:"Microsoft YaHei",sans-serif;color:#182552;padding:32px;background:#f2f4f8}.card{max-width:800px;margin:auto;background:#fff;border:2px solid #182552;padding:32px;box-shadow:0 12px 35px #18255218}.head{display:flex;justify-content:space-between;border-bottom:3px double #182552;padding-bottom:18px}.brand{font-size:28px;font-weight:800}.code{font-family:monospace}.meta{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:24px 0}.meta div{border-bottom:1px solid #ddd;padding:8px}.meta span{color:#777;font-size:12px;margin-right:12px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd2df;padding:12px;text-align:left}th{background:#f3f5f9}.notice{margin-top:20px;padding:15px;background:#fff7e6;color:#704b12;font-size:13px}.footer{display:flex;justify-content:space-between;margin-top:22px;color:#777;font-size:12px}@media print{body{background:#fff;padding:0}.card{box-shadow:none}}</style></head><body><main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div class="code">${escapeHtml(registration.admitCard.number)}</div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong></div><div><span>考场 / 座位</span><strong>${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考场</th></tr></thead><tbody>${rows}</tbody></table><div class="notice">考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main></body></html>`;
|
const rows = subjects.map(subject => {
|
||||||
|
const assignment = assignments.get(subject.id) || {};
|
||||||
|
return `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}—${escapeHtml(subject.end)}</td><td>${escapeHtml(assignment.roomName || assignment.room || '待定')}</td><td>${escapeHtml(assignment.seat || '—')}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(exam.name)}准考证</title><style>body{font-family:"Microsoft YaHei",sans-serif;color:#182552;padding:32px;background:#f2f4f8}.card{max-width:800px;margin:auto;background:#fff;border:2px solid #182552;padding:32px;box-shadow:0 12px 35px #18255218}.head{display:flex;justify-content:space-between;border-bottom:3px double #182552;padding-bottom:18px}.brand{font-size:28px;font-weight:800}.code{font-family:monospace}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:24px 0}.meta div{border-bottom:1px solid #ddd;padding:8px}.meta span{color:#777;font-size:12px;margin-right:12px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd2df;padding:12px;text-align:left}th{background:#f3f5f9}.notice{margin-top:20px;padding:15px;background:#fff7e6;color:#704b12;font-size:13px}.footer{display:flex;justify-content:space-between;margin-top:22px;color:#777;font-size:12px}@media print{body{background:#fff;padding:0}.card{box-shadow:none}}</style></head><body><main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div class="code">${escapeHtml(registration.admitCard.number)}</div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>固定考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考场</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><div class="notice">所有科目均安排在同一考点,但不同科目可能对应不同考场和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeContext = {
|
const routeContext = {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function createAdminViews(context) {
|
|||||||
const meta = {
|
const meta = {
|
||||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证编排', '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。'],
|
||||||
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||||
@@ -40,12 +40,12 @@ export function createAdminViews(context) {
|
|||||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||||
try {
|
try {
|
||||||
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||||
const data = await api(`/api/admin/${endpoint}`);
|
const data = await api(`/api/admin/${endpoint}`);
|
||||||
state.pageData = data;
|
state.pageData = data;
|
||||||
const content = {
|
const content = {
|
||||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
||||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data),
|
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
|
||||||
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
||||||
}[page]();
|
}[page]();
|
||||||
@@ -87,9 +87,23 @@ export function createAdminViews(context) {
|
|||||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminAdmit(registrations) {
|
function adminAdmit(data) {
|
||||||
const approved = registrations.filter(reg => reg.status === 'approved');
|
const approved = data.registrations || [];
|
||||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号"></label><p>仅已通过报名审核的考生可生成准考证。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>准考证号</th><th>考点 / 考场</th><th>下载时间</th><th>操作</th></tr></thead><tbody>${approved.map(reg => `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.phone)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td>${reg.subjects.map(subject => h(subject.name)).join('、')}</td><td class="mono">${h(reg.admitCard?.number || '尚未生成')}</td><td>${reg.admitCard ? `${h(reg.admitCard.testCenter)}<small>${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号</small>` : '—'}</td><td>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</td><td><button class="row-action ${reg.admitCard ? '' : 'primary'}" data-action="generate-admit" data-id="${h(reg.id)}">${reg.admitCard ? '重新查看' : '生成准考证'}</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
const selectedExam = data.exams.find(exam => exam.approvedCount > 0) || data.exams[0];
|
||||||
|
const selectedPlan = selectedExam?.plan;
|
||||||
|
const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code;
|
||||||
|
const form = selectedExam ? `<section class="panel arrangement-config"><div class="panel-title"><div><h2>整场编排控制台</h2><p>先预检容量与档案,再以一个事务生成或替换整套编排。</p></div><span>${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位</span></div><form data-form="admission-arrangement"><div class="arrangement-fields"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selectedExam.id ? 'selected' : ''}>${h(exam.name)}(审核通过 ${exam.approvedCount} 人 / 已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label><label><span>混编范围 *</span><select name="mixingScope" required>${data.mixingScopes.map(item => `<option value="${h(item.code)}" ${item.code === (selectedPlan?.mixingScope || 'school') ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>准考证号规则 *</span><select name="numberRuleId" required>${data.rules.map(rule => `<option value="${h(rule.id)}" ${rule.id === selectedPlan?.numberRuleId ? 'selected' : ''}>${h(rule.name)}</option>`).join('')}</select></label><label><span>稳定编排种子</span><input name="seed" value="${h(selectedPlan?.randomSeed || selectedExam.code)}" maxlength="80"><small>相同数据与种子会得到相同顺序,便于复核。</small></label></div><div class="arrangement-actions"><button type="button" class="ghost-button" data-action="preview-arrangement">仅预检,不写入</button><button type="submit" class="solid-button">${selectedPlan ? '重新编排整场考试' : '生成整场编排'}</button></div></form><div data-arrangement-preview></div></section>` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。');
|
||||||
|
const ruleCards = `<section class="arrangement-rules">${data.rules.map(rule => `<article><span>号码规则</span><h3>${h(rule.name)}</h3><p>${h(rule.description)}</p><code>${h(rule.example)}</code></article>`).join('')}</section>`;
|
||||||
|
const planCards = data.plans.length ? `<section class="arrangement-plan-grid">${data.plans.map(plan => `<article class="panel"><header><div><span>${h(scopeName(plan.mixingScope))}</span><h3>${h(plan.examName)}</h3></div><time>${formatDate(plan.generatedAt, true)}</time></header><div><strong>${plan.candidateCount}</strong><small>名考生</small><strong>${plan.centerCount}</strong><small>个考点</small><strong>${plan.subjectCombinationCount}</strong><small>种科目组合</small></div><p>${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%</p>${plan.warnings.length ? `<ul>${plan.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<p class="arrangement-ok">容量、档案与时间冲突检查通过</p>'}</article>`).join('')}</section>` : '';
|
||||||
|
const rows = approved.map(reg => {
|
||||||
|
const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item]));
|
||||||
|
const subjects = reg.subjects.map(subject => {
|
||||||
|
const assignment = assignments.get(subject.id);
|
||||||
|
return `<span><b>${h(subject.name)}</b><small>${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)} 号` : '待编排'}</small></span>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td class="mono">${h(reg.admitCard?.number || '待编排')}</td><td>${h(reg.admitCard?.testCenter || '—')}</td><td><div class="subject-room-list">${subjects}</div></td><td>${reg.admitCard ? `<button class="row-action" data-action="generate-admit" data-id="${h(reg.id)}">查看</button>` : '—'}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `${form}${ruleCards}${planCards}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号或考点"></label><p>同一考生所有科目固定在一个考点;考场和座位按科目分别展示。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点</th><th>分科考场 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminResults(data) {
|
function adminResults(data) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function createCandidateViews(context) {
|
|||||||
if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]];
|
if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]];
|
||||||
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], ...operations, core[3]];
|
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
||||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3]];
|
||||||
}
|
}
|
||||||
|
|
||||||
function portalShell(role, page, content, title, description) {
|
function portalShell(role, page, content, title, description) {
|
||||||
@@ -131,7 +131,16 @@ export function createCandidateViews(context) {
|
|||||||
|
|
||||||
function candidateAdmit(registrations) {
|
function candidateAdmit(registrations) {
|
||||||
const cards = registrations.filter(reg => reg.admitCard);
|
const cards = registrations.filter(reg => reg.admitCard);
|
||||||
return cards.length ? `<div class="admit-list">${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场 / 座位</dt><dd>${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`; }).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态');
|
return cards.length ? `<div class="admit-list">${cards.map(reg => {
|
||||||
|
const now = Date.now();
|
||||||
|
const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime();
|
||||||
|
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
|
||||||
|
const subjectRows = reg.subjects.map(subject => {
|
||||||
|
const assignment = assignments.get(subject.id) || {};
|
||||||
|
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '待定')} · ${h(assignment.seat || '—')} 号</small></span>`;
|
||||||
|
}).join('');
|
||||||
|
return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>逐科安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`;
|
||||||
|
}).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态');
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateResults(data) {
|
function candidateResults(data) {
|
||||||
|
|||||||
+56
-2
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
return {
|
||||||
meta: { version: 8, createdAt: nowIso() },
|
meta: { version: 9, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: {
|
organization: {
|
||||||
name: '海州市教育考试中心',
|
name: '海州市教育考试中心',
|
||||||
@@ -76,7 +76,16 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
{
|
{
|
||||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||||
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
|
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
|
||||||
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
|
admitCard: {
|
||||||
|
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点',
|
||||||
|
room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [
|
||||||
|
{ subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||||
|
{ subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '003', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||||
|
{ subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '006', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||||
|
{ subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '009', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||||
|
{ subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '011', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
results: [
|
results: [
|
||||||
@@ -96,6 +105,51 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
],
|
],
|
||||||
centerChangeRequests: [],
|
centerChangeRequests: [],
|
||||||
centerChangeRooms: [],
|
centerChangeRooms: [],
|
||||||
|
admissionNumberRules: [
|
||||||
|
{
|
||||||
|
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||||
|
description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'district_code', label: '县区编号', width: 6 },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号',
|
||||||
|
description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'district_code', label: '县区号', width: 6 },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'sequence', label: '流水号', width: 4 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号',
|
||||||
|
description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'center_school_code', label: '考点学校代码' },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号',
|
||||||
|
description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'candidate_school_code', label: '考生学校代码' },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
arrangementPlans: [
|
||||||
|
{
|
||||||
|
id: 'arrangement_demo_2026', examId, numberRuleId: 'admit_rule_district_room_seat', mixingScope: 'city', randomSeed: 'EX-2026-AUT',
|
||||||
|
candidateCount: 1, centerCount: 1, subjectAssignmentCount: 5, subjectCombinationCount: 1, sameSchoolCenterRate: 0,
|
||||||
|
warnings: [], generatedBy: adminId, generatedAt: '2026-07-19T02:00:00.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
candidateAccountBatches: [],
|
candidateAccountBatches: [],
|
||||||
candidateAccountBatchItems: [],
|
candidateAccountBatchItems: [],
|
||||||
numberRules: [
|
numberRules: [
|
||||||
|
|||||||
@@ -95,7 +95,30 @@ export function createMysqlAdapter(context) {
|
|||||||
if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number');
|
if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number');
|
||||||
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
||||||
if (existing.length) {
|
if (existing.length) {
|
||||||
const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1');
|
const [metadataRows] = await pool.execute('SELECT app_version, schema_version FROM schema_metadata WHERE id = 1');
|
||||||
|
if (Number(metadataRows[0]?.schema_version || 1) < 9) {
|
||||||
|
await pool.execute('DROP TABLE IF EXISTS admit_card_subjects');
|
||||||
|
await pool.execute('DROP TABLE IF EXISTS admit_cards');
|
||||||
|
await pool.execute('DROP TABLE IF EXISTS exam_arrangement_plans');
|
||||||
|
await pool.execute('DROP TABLE IF EXISTS admission_number_rules');
|
||||||
|
const admissionTables = ['admission_number_rules', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects'];
|
||||||
|
for (const table of admissionTables) {
|
||||||
|
const statement = mysqlSchema.find(item => item.includes(`CREATE TABLE IF NOT EXISTS ${table} (`));
|
||||||
|
if (!statement) throw new Error(`缺少 ${table} 的 MySQL 表定义`);
|
||||||
|
await pool.execute(statement);
|
||||||
|
}
|
||||||
|
const extension = seed();
|
||||||
|
for (const rule of extension.admissionNumberRules) await pool.execute(
|
||||||
|
`INSERT INTO admission_number_rules (
|
||||||
|
id, code, name, description, separator, segments_json, example, active, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
|
||||||
|
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt]
|
||||||
|
);
|
||||||
|
await pool.execute('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1');
|
||||||
|
metadataRows[0].schema_version = 9;
|
||||||
|
metadataRows[0].app_version = 9;
|
||||||
|
}
|
||||||
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();
|
||||||
@@ -275,7 +298,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, 7, ?, ?, ?)
|
VALUES (1, 9, ?, ?, ?)
|
||||||
`, [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);
|
||||||
|
|||||||
+107
-13
@@ -149,15 +149,56 @@ export const sqliteSchema = `
|
|||||||
PRIMARY KEY (registration_id, subject_id)
|
PRIMARY KEY (registration_id, subject_id)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admission_number_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
separator TEXT NOT NULL DEFAULT '',
|
||||||
|
segments_json TEXT NOT NULL,
|
||||||
|
example TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
|
||||||
|
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
|
||||||
|
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
|
||||||
|
random_seed TEXT NOT NULL,
|
||||||
|
candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
|
||||||
|
center_count INTEGER NOT NULL CHECK (center_count >= 0),
|
||||||
|
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
|
||||||
|
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
|
||||||
|
same_school_center_rate REAL NOT NULL,
|
||||||
|
warnings_json TEXT NOT NULL,
|
||||||
|
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
generated_at TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admit_cards (
|
CREATE TABLE IF NOT EXISTS admit_cards (
|
||||||
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||||
card_number TEXT NOT NULL UNIQUE,
|
card_number TEXT NOT NULL UNIQUE,
|
||||||
|
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||||
test_center TEXT NOT NULL,
|
test_center TEXT NOT NULL,
|
||||||
room TEXT NOT NULL,
|
|
||||||
seat TEXT NOT NULL,
|
|
||||||
generated_at TEXT NOT NULL
|
generated_at TEXT NOT NULL
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admit_card_subjects (
|
||||||
|
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||||
|
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||||
|
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL,
|
||||||
|
room TEXT NOT NULL,
|
||||||
|
room_code TEXT NOT NULL,
|
||||||
|
exam_room_code TEXT NOT NULL,
|
||||||
|
seat TEXT NOT NULL,
|
||||||
|
subject_signature TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (registration_id, subject_id),
|
||||||
|
UNIQUE (subject_id, room_id, seat)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS results (
|
CREATE TABLE IF NOT EXISTS results (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
@@ -357,6 +398,8 @@ export const sqliteSchema = `
|
|||||||
CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position);
|
CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position);
|
||||||
CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status);
|
CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
|
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type);
|
CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code);
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code);
|
CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code);
|
||||||
@@ -543,17 +586,6 @@ export const mysqlSchema = [
|
|||||||
CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||||||
) 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 admit_cards (
|
|
||||||
registration_id VARCHAR(64) NOT NULL,
|
|
||||||
card_number VARCHAR(100) NOT NULL,
|
|
||||||
test_center VARCHAR(200) NOT NULL,
|
|
||||||
room VARCHAR(100) NOT NULL,
|
|
||||||
seat VARCHAR(30) NOT NULL,
|
|
||||||
generated_at VARCHAR(35) NOT NULL,
|
|
||||||
PRIMARY KEY (registration_id),
|
|
||||||
UNIQUE KEY uq_admit_cards_number (card_number),
|
|
||||||
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS results (
|
`CREATE TABLE IF NOT EXISTS results (
|
||||||
id VARCHAR(64) NOT NULL,
|
id VARCHAR(64) NOT NULL,
|
||||||
registration_id VARCHAR(64) NOT NULL,
|
registration_id VARCHAR(64) NOT NULL,
|
||||||
@@ -616,6 +648,68 @@ export const mysqlSchema = [
|
|||||||
KEY idx_rooms_center (center_id, status, code),
|
KEY idx_rooms_center (center_id, status, code),
|
||||||
CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE
|
CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE
|
||||||
) 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_number_rules (
|
||||||
|
id VARCHAR(64) NOT NULL,
|
||||||
|
code VARCHAR(80) NOT NULL,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
separator VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
segments_json JSON NOT NULL,
|
||||||
|
example VARCHAR(120) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_admission_rules_code (code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
|
||||||
|
id VARCHAR(64) NOT NULL,
|
||||||
|
exam_id VARCHAR(64) NOT NULL,
|
||||||
|
number_rule_id VARCHAR(64) NOT NULL,
|
||||||
|
mixing_scope ENUM('class', 'school', 'district', 'city', 'province') NOT NULL,
|
||||||
|
random_seed VARCHAR(80) NOT NULL,
|
||||||
|
candidate_count INT UNSIGNED NOT NULL,
|
||||||
|
center_count INT UNSIGNED NOT NULL,
|
||||||
|
subject_assignment_count INT UNSIGNED NOT NULL,
|
||||||
|
subject_combination_count INT UNSIGNED NOT NULL,
|
||||||
|
same_school_center_rate DOUBLE NOT NULL,
|
||||||
|
warnings_json JSON NOT NULL,
|
||||||
|
generated_by VARCHAR(64) NULL,
|
||||||
|
generated_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_arrangement_plans_exam (exam_id),
|
||||||
|
CONSTRAINT fk_arrangement_plans_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_arrangement_plans_rule FOREIGN KEY (number_rule_id) REFERENCES admission_number_rules(id),
|
||||||
|
CONSTRAINT fk_arrangement_plans_generator FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS admit_cards (
|
||||||
|
registration_id VARCHAR(64) NOT NULL,
|
||||||
|
plan_id VARCHAR(64) NOT NULL,
|
||||||
|
card_number VARCHAR(100) NOT NULL,
|
||||||
|
center_id VARCHAR(64) NULL,
|
||||||
|
test_center VARCHAR(200) NOT NULL,
|
||||||
|
generated_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (registration_id),
|
||||||
|
UNIQUE KEY uq_admit_cards_number (card_number),
|
||||||
|
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_admit_cards_plan FOREIGN KEY (plan_id) REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_admit_cards_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS admit_card_subjects (
|
||||||
|
registration_id VARCHAR(64) NOT NULL,
|
||||||
|
subject_id VARCHAR(64) NOT NULL,
|
||||||
|
room_id VARCHAR(64) NULL,
|
||||||
|
room VARCHAR(120) NOT NULL,
|
||||||
|
room_code VARCHAR(40) NOT NULL,
|
||||||
|
exam_room_code VARCHAR(20) NOT NULL,
|
||||||
|
seat VARCHAR(30) NOT NULL,
|
||||||
|
subject_signature VARCHAR(1000) NOT NULL,
|
||||||
|
PRIMARY KEY (registration_id, subject_id),
|
||||||
|
UNIQUE KEY uq_admit_subject_room_seat (subject_id, room_id, seat),
|
||||||
|
KEY idx_admit_subjects_room (subject_id, room_id, seat),
|
||||||
|
CONSTRAINT fk_admit_subjects_card FOREIGN KEY (registration_id) REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_admit_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_admit_subjects_room FOREIGN KEY (room_id) REFERENCES test_rooms(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS center_change_requests (
|
`CREATE TABLE IF NOT EXISTS center_change_requests (
|
||||||
id VARCHAR(64) NOT NULL,
|
id VARCHAR(64) NOT NULL,
|
||||||
center_id VARCHAR(64) NULL,
|
center_id VARCHAR(64) NULL,
|
||||||
|
|||||||
@@ -114,6 +114,64 @@ export function createSqliteAdapter(context) {
|
|||||||
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
|
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
|
||||||
|
|
||||||
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
|
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
|
||||||
|
if (existingSystem && Number(existingSystem.schema_version || 1) < 9) {
|
||||||
|
const extension = seed();
|
||||||
|
connection.exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
connection.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
connection.exec(`
|
||||||
|
DROP TABLE IF EXISTS admit_card_subjects;
|
||||||
|
DROP TABLE IF EXISTS admit_cards;
|
||||||
|
DROP TABLE IF EXISTS exam_arrangement_plans;
|
||||||
|
DROP TABLE IF EXISTS admission_number_rules;
|
||||||
|
CREATE TABLE admission_number_rules (
|
||||||
|
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL,
|
||||||
|
separator TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL, example TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE exam_arrangement_plans (
|
||||||
|
id TEXT PRIMARY KEY, exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
|
||||||
|
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
|
||||||
|
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
|
||||||
|
random_seed TEXT NOT NULL, candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
|
||||||
|
center_count INTEGER NOT NULL CHECK (center_count >= 0),
|
||||||
|
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
|
||||||
|
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
|
||||||
|
same_school_center_rate REAL NOT NULL, warnings_json TEXT NOT NULL,
|
||||||
|
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, generated_at TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE admit_cards (
|
||||||
|
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||||
|
card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||||
|
test_center TEXT NOT NULL, generated_at TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE admit_card_subjects (
|
||||||
|
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||||
|
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||||
|
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL,
|
||||||
|
exam_room_code TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
|
||||||
|
CREATE INDEX idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
|
||||||
|
`);
|
||||||
|
for (const rule of extension.admissionNumberRules) connection.prepare(
|
||||||
|
`INSERT INTO admission_number_rules (
|
||||||
|
id, code, name, description, separator, segments_json, example, active, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
|
||||||
|
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt);
|
||||||
|
connection.prepare('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1').run();
|
||||||
|
connection.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
connection.exec('ROLLBACK');
|
||||||
|
connection.close();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||||
const extension = seed();
|
const extension = seed();
|
||||||
connection.exec('BEGIN IMMEDIATE');
|
connection.exec('BEGIN IMMEDIATE');
|
||||||
@@ -271,7 +329,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, 7, ?, ?, ?)
|
VALUES (1, 9, ?, ?, ?)
|
||||||
`).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');
|
||||||
|
|||||||
+70
-19
@@ -1,3 +1,5 @@
|
|||||||
|
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||||
|
|
||||||
export function createAdminRoutes(context) {
|
export function createAdminRoutes(context) {
|
||||||
const {
|
const {
|
||||||
database,
|
database,
|
||||||
@@ -65,7 +67,7 @@ export function createAdminRoutes(context) {
|
|||||||
date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
|
date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
|
||||||
start: cleanText(structured ? item.start : '', 5) || '09:00',
|
start: cleanText(structured ? item.start : '', 5) || '09:00',
|
||||||
end: cleanText(structured ? item.end : '', 5) || '11:00',
|
end: cleanText(structured ? item.end : '', 5) || '11:00',
|
||||||
fee: Number(structured ? item.fee : 0),
|
fee: Number(structured ? item.fee ?? 0 : 0),
|
||||||
fullScore,
|
fullScore,
|
||||||
passScore: Number(structured ? item.passScore : fullScore * .6),
|
passScore: Number(structured ? item.passScore : fullScore * .6),
|
||||||
order: index + 1
|
order: index + 1
|
||||||
@@ -590,6 +592,38 @@ export function createAdminRoutes(context) {
|
|||||||
});
|
});
|
||||||
return sendJson(response, 200, { ok: true, registrations });
|
return sendJson(response, 200, { ok: true, registrations });
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const registrations = db.registrations.filter(item => item.status === 'approved').map(registration => {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
|
||||||
|
});
|
||||||
|
const plans = db.arrangementPlans.map(plan => ({
|
||||||
|
...plan,
|
||||||
|
examName: db.exams.find(item => item.id === plan.examId)?.name || '',
|
||||||
|
ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '',
|
||||||
|
mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope
|
||||||
|
}));
|
||||||
|
const exams = db.exams.map(exam => ({
|
||||||
|
...publicExam(exam),
|
||||||
|
approvedCount: db.registrations.filter(item => item.examId === exam.id && item.status === 'approved').length,
|
||||||
|
arrangedCount: db.registrations.filter(item => item.examId === exam.id && item.admitCard).length,
|
||||||
|
plan: plans.find(item => item.examId === exam.id) || null
|
||||||
|
}));
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
exams,
|
||||||
|
registrations,
|
||||||
|
plans,
|
||||||
|
rules: db.admissionNumberRules.filter(item => item.active),
|
||||||
|
mixingScopes: admissionMixingScopes,
|
||||||
|
centers: db.testCenters.filter(item => item.status === 'active').map(center => ({
|
||||||
|
...center,
|
||||||
|
roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length,
|
||||||
|
capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && registrationMatch) {
|
if (request.method === 'PATCH' && registrationMatch) {
|
||||||
if (!requirePermission(user, response, 'registrations.review')) return true;
|
if (!requirePermission(user, response, 'registrations.review')) return true;
|
||||||
@@ -627,7 +661,6 @@ export function createAdminRoutes(context) {
|
|||||||
await database.processWorkflow(instance, action, registration, log);
|
await database.processWorkflow(instance, action, registration, log);
|
||||||
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
}
|
}
|
||||||
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
|
||||||
const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/);
|
const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && scoreAppealMatch) {
|
if (request.method === 'PATCH' && scoreAppealMatch) {
|
||||||
if (!requirePermission(user, response, 'workflows.inbox')) return true;
|
if (!requirePermission(user, response, 'workflows.inbox')) return true;
|
||||||
@@ -661,26 +694,44 @@ export function createAdminRoutes(context) {
|
|||||||
await database.processWorkflow(instance, action, null, log);
|
await database.processWorkflow(instance, action, null, log);
|
||||||
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && admitMatch) {
|
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
|
||||||
|
if (request.method === 'POST' && arrangementMatch) {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
const body = await readJson(request);
|
||||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
const generatedAt = nowIso();
|
||||||
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
|
const result = buildAdmissionArrangement(db, {
|
||||||
if (!registration.admitCard) {
|
examId: arrangementMatch[1],
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
mixingScope: cleanText(body.mixingScope, 20),
|
||||||
const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0');
|
numberRuleId: cleanText(body.numberRuleId, 64),
|
||||||
registration.admitCard = {
|
seed: cleanText(body.seed, 80),
|
||||||
number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`,
|
generatedAt
|
||||||
testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80),
|
});
|
||||||
room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`,
|
if (arrangementMatch[2]) return sendJson(response, 200, {
|
||||||
seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'),
|
ok: true,
|
||||||
generatedAt: nowIso()
|
preview: true,
|
||||||
|
summary: result.summary,
|
||||||
|
warnings: result.warnings,
|
||||||
|
samples: result.cards.slice(0, 5)
|
||||||
|
});
|
||||||
|
const plan = {
|
||||||
|
id: uid('arrangement'),
|
||||||
|
examId: result.exam.id,
|
||||||
|
numberRuleId: result.rule.id,
|
||||||
|
mixingScope: result.mixingScope,
|
||||||
|
randomSeed: result.seed,
|
||||||
|
...result.summary,
|
||||||
|
warnings: result.warnings,
|
||||||
|
generatedBy: user.id,
|
||||||
|
generatedAt
|
||||||
};
|
};
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
const log = logAction(db, user, db.arrangementPlans.some(item => item.examId === result.exam.id) ? '重新编排准考证' : '批量编排准考证',
|
||||||
const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
|
`${result.exam.name} · ${plan.candidateCount} 人 · ${plan.centerCount} 个考点 · ${result.rule.name}`);
|
||||||
await database.createAdmitCard(registration.id, registration.admitCard, log);
|
await database.saveAdmissionArrangement(plan, result.cards, log);
|
||||||
|
return sendJson(response, 200, { ok: true, plan, summary: result.summary, warnings: result.warnings, cards: result.cards });
|
||||||
}
|
}
|
||||||
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
|
const legacyAdmitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||||
|
if (request.method === 'POST' && legacyAdmitMatch) {
|
||||||
|
return sendError(response, 410, '单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成');
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/exams') {
|
if (request.method === 'GET' && pathname === '/api/admin/exams') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
export const admissionMixingScopes = [
|
||||||
|
{ code: 'class', name: '班内混编', description: '以班级为边界,同班考生按科目组合穿插编排。' },
|
||||||
|
{ code: 'school', name: '校内混编', description: '同校跨班混编,优先安排在本校考点。' },
|
||||||
|
{ code: 'district', name: '县区内混编', description: '同县区跨学校混编,优先使用本县区考点。' },
|
||||||
|
{ code: 'city', name: '市内混编', description: '同市跨县区混编,优先使用本市考点。' },
|
||||||
|
{ code: 'province', name: '省内混编', description: '全省范围混编,按容量和科目组合选择考点。' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mixingScopeCodes = new Set(admissionMixingScopes.map(item => item.code));
|
||||||
|
|
||||||
|
function arrangementError(message, status = 409) {
|
||||||
|
return Object.assign(new Error(message), { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCode(value, fallback = '') {
|
||||||
|
return String(value || fallback).replace(/[^0-9A-Z]/gi, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashText(value) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (const char of String(value)) {
|
||||||
|
hash ^= char.charCodeAt(0);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableCompare(seed, left, right) {
|
||||||
|
return hashText(`${seed}:${left.id}`) - hashText(`${seed}:${right.id}`) || left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeValue(profile, scope) {
|
||||||
|
if (scope === 'class') return profile.classId;
|
||||||
|
if (scope === 'school') return profile.schoolId;
|
||||||
|
if (scope === 'district') return profile.districtCode;
|
||||||
|
if (scope === 'city') return profile.cityCode;
|
||||||
|
return profile.provinceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerMatchesProfile(center, profile, scope) {
|
||||||
|
if (scope === 'class' || scope === 'school') return center.schoolId === profile.schoolId;
|
||||||
|
if (scope === 'district') return Boolean(profile.districtCode && center.districtCode === profile.districtCode);
|
||||||
|
if (scope === 'city') return Boolean(profile.cityCode && center.cityCode === profile.cityCode);
|
||||||
|
return Boolean(profile.provinceCode && center.provinceCode === profile.provinceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localityScore(center, profile) {
|
||||||
|
if (center.schoolId === profile.schoolId) return 5000;
|
||||||
|
if (profile.districtCode && center.districtCode === profile.districtCode) return 1000;
|
||||||
|
if (profile.cityCode && center.cityCode === profile.cityCode) return 300;
|
||||||
|
if (profile.provinceCode && center.provinceCode === profile.provinceCode) return 100;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutes(value) {
|
||||||
|
const [hour, minute] = String(value || '').split(':').map(Number);
|
||||||
|
return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlappingSubjects(subjects) {
|
||||||
|
for (let left = 0; left < subjects.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < subjects.length; right += 1) {
|
||||||
|
const first = subjects[left];
|
||||||
|
const second = subjects[right];
|
||||||
|
if (first.date !== second.date) continue;
|
||||||
|
const firstStart = minutes(first.start);
|
||||||
|
const firstEnd = minutes(first.end);
|
||||||
|
const secondStart = minutes(second.start);
|
||||||
|
const secondEnd = minutes(second.end);
|
||||||
|
if ([firstStart, firstEnd, secondStart, secondEnd].every(Number.isFinite)
|
||||||
|
&& firstStart < secondEnd && secondStart < firstEnd) return [first, second];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuleSegments(rule) {
|
||||||
|
if (Array.isArray(rule.segments)) return rule.segments;
|
||||||
|
try { return JSON.parse(rule.segmentsJson || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentValue(segment, sources, sequence) {
|
||||||
|
const source = segment.source === 'sequence' ? String(sequence) : String(sources[segment.source] || '');
|
||||||
|
if (!source) throw arrangementError(`准考证号规则需要“${segment.label || segment.source}”,但考生或考点档案中缺少该值`);
|
||||||
|
const width = Math.max(0, Number(segment.width || 0));
|
||||||
|
return width ? source.padStart(width, '0') : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAdmissionNumber(rule, sources, sequence) {
|
||||||
|
const segments = parseRuleSegments(rule);
|
||||||
|
if (!segments.length) throw arrangementError('所选准考证号规则没有可用的组成段');
|
||||||
|
return segments.map(segment => segmentValue(segment, sources, sequence)).join(rule.separator || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceGroup(rule, sources) {
|
||||||
|
if (rule.code.startsWith('district_')) return sources.district_code;
|
||||||
|
if (rule.code.startsWith('center_school_')) return sources.center_school_code;
|
||||||
|
if (rule.code.startsWith('candidate_school_')) return sources.candidate_school_code;
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAdmissionArrangement(db, options) {
|
||||||
|
const exam = db.exams.find(item => item.id === options.examId);
|
||||||
|
if (!exam) throw arrangementError('考试不存在', 404);
|
||||||
|
const mixingScope = String(options.mixingScope || 'school');
|
||||||
|
if (!mixingScopeCodes.has(mixingScope)) throw arrangementError('请选择有效的混编范围', 400);
|
||||||
|
const rule = db.admissionNumberRules.find(item => item.id === options.numberRuleId && item.active !== false);
|
||||||
|
if (!rule) throw arrangementError('请选择有效的准考证号规则', 400);
|
||||||
|
const seed = String(options.seed || exam.code || exam.id).slice(0, 80);
|
||||||
|
const warnings = [];
|
||||||
|
const warn = message => { if (!warnings.includes(message)) warnings.push(message); };
|
||||||
|
const subjectOrder = new Map(exam.subjects.map((subject, index) => [subject.id, index]));
|
||||||
|
const registrations = db.registrations.filter(item => item.examId === exam.id && item.status === 'approved');
|
||||||
|
if (!registrations.length) throw arrangementError('该考试没有已审核通过的报名,无法编排');
|
||||||
|
|
||||||
|
const candidates = registrations.map(registration => {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
if (!profile) throw arrangementError(`报名 ${registration.id} 缺少考生档案`);
|
||||||
|
const boundary = scopeValue(profile, mixingScope);
|
||||||
|
if (!boundary) throw arrangementError(`${profile.name} 缺少${admissionMixingScopes.find(item => item.code === mixingScope)?.name.replace('内混编', '') || '范围'}信息`);
|
||||||
|
const subjects = registration.subjectIds.map(id => exam.subjects.find(subject => subject.id === id)).filter(Boolean);
|
||||||
|
if (subjects.length !== registration.subjectIds.length || !subjects.length) throw arrangementError(`${profile.name} 的报考科目无效`);
|
||||||
|
const overlap = overlappingSubjects(subjects);
|
||||||
|
if (overlap) throw arrangementError(`${profile.name} 报考的“${overlap[0].name}”与“${overlap[1].name}”时间冲突`);
|
||||||
|
const orderedSubjectIds = subjects.sort((a, b) => subjectOrder.get(a.id) - subjectOrder.get(b.id)).map(subject => subject.id);
|
||||||
|
return {
|
||||||
|
id: registration.id,
|
||||||
|
registration,
|
||||||
|
profile,
|
||||||
|
subjectIds: orderedSubjectIds,
|
||||||
|
signature: orderedSubjectIds.join('|'),
|
||||||
|
scopeKey: `${mixingScope}:${boundary}`,
|
||||||
|
centerId: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCenters = db.testCenters.filter(center => center.status === 'active').map(center => {
|
||||||
|
const allRooms = db.testRooms.filter(room => room.centerId === center.id && room.status === 'active');
|
||||||
|
const regularRooms = allRooms.filter(room => room.roomType !== 'spare');
|
||||||
|
const rooms = regularRooms.length ? regularRooms : allRooms;
|
||||||
|
if (!regularRooms.length && allRooms.length) warn(`${center.name} 没有普通启用考场,本次将使用备用考场`);
|
||||||
|
return { ...center, rooms, allRooms };
|
||||||
|
}).filter(center => center.rooms.length);
|
||||||
|
if (!activeCenters.length) throw arrangementError('没有可用的启用考点和考场');
|
||||||
|
|
||||||
|
const remaining = new Map(activeCenters.map(center => [center.id, new Map(
|
||||||
|
exam.subjects.map(subject => [subject.id, center.rooms.reduce((sum, room) => sum + Number(room.capacity), 0)])
|
||||||
|
)]));
|
||||||
|
const assignedCounts = new Map(activeCenters.map(center => [center.id, 0]));
|
||||||
|
const affinity = new Map();
|
||||||
|
const groups = new Map();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const key = `${candidate.scopeKey}:${candidate.signature}`;
|
||||||
|
const group = groups.get(key) || [];
|
||||||
|
group.push(candidate);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
const orderedGroups = [...groups.entries()].sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]));
|
||||||
|
|
||||||
|
for (const [, group] of orderedGroups) {
|
||||||
|
group.sort((left, right) => stableCompare(seed, left, right));
|
||||||
|
for (const candidate of group) {
|
||||||
|
const eligible = activeCenters.filter(center => candidate.subjectIds.every(subjectId => (remaining.get(center.id).get(subjectId) || 0) > 0));
|
||||||
|
if (!eligible.length) throw arrangementError(`${candidate.profile.name} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围`);
|
||||||
|
const local = eligible.filter(center => centerMatchesProfile(center, candidate.profile, mixingScope));
|
||||||
|
if (!local.length) warn(`${candidate.profile.name} 所属范围内没有足够考点,已跨范围使用可用考点`);
|
||||||
|
const pool = local.length ? local : eligible;
|
||||||
|
const selected = [...pool].sort((left, right) => {
|
||||||
|
const leftAffinity = affinity.get(`${left.id}:${candidate.signature}`) || 0;
|
||||||
|
const rightAffinity = affinity.get(`${right.id}:${candidate.signature}`) || 0;
|
||||||
|
const leftCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(left.id).get(id)));
|
||||||
|
const rightCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(right.id).get(id)));
|
||||||
|
const leftScore = leftAffinity * 100000 + localityScore(left, candidate.profile) + leftCapacity - (assignedCounts.get(left.id) || 0);
|
||||||
|
const rightScore = rightAffinity * 100000 + localityScore(right, candidate.profile) + rightCapacity - (assignedCounts.get(right.id) || 0);
|
||||||
|
return rightScore - leftScore || left.code.localeCompare(right.code);
|
||||||
|
})[0];
|
||||||
|
candidate.centerId = selected.id;
|
||||||
|
candidate.subjectIds.forEach(subjectId => remaining.get(selected.id).set(subjectId, remaining.get(selected.id).get(subjectId) - 1));
|
||||||
|
assignedCounts.set(selected.id, (assignedCounts.get(selected.id) || 0) + 1);
|
||||||
|
affinity.set(`${selected.id}:${candidate.signature}`, (affinity.get(`${selected.id}:${candidate.signature}`) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = new Map(candidates.map(candidate => [candidate.id, []]));
|
||||||
|
const roomUses = [];
|
||||||
|
for (const subject of exam.subjects) {
|
||||||
|
for (const center of activeCenters) {
|
||||||
|
const subjectCandidates = candidates.filter(candidate => candidate.centerId === center.id && candidate.subjectIds.includes(subject.id));
|
||||||
|
if (!subjectCandidates.length) continue;
|
||||||
|
subjectCandidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey)
|
||||||
|
|| left.signature.localeCompare(right.signature) || stableCompare(seed, left, right));
|
||||||
|
let roomIndex = 0;
|
||||||
|
let seatIndex = 0;
|
||||||
|
let currentScopeKey = '';
|
||||||
|
const rooms = [...center.rooms].sort((left, right) => left.code.localeCompare(right.code) || left.id.localeCompare(right.id));
|
||||||
|
for (const candidate of subjectCandidates) {
|
||||||
|
if (currentScopeKey && candidate.scopeKey !== currentScopeKey && seatIndex > 0) { roomIndex += 1; seatIndex = 0; }
|
||||||
|
currentScopeKey = candidate.scopeKey;
|
||||||
|
while (roomIndex < rooms.length && seatIndex >= Number(rooms[roomIndex].capacity)) { roomIndex += 1; seatIndex = 0; }
|
||||||
|
const room = rooms[roomIndex];
|
||||||
|
if (!room) throw arrangementError(`${center.name} 在“${subject.name}”科目下按${admissionMixingScopes.find(item => item.code === mixingScope)?.name || '当前范围'}隔离后考场不足;请增加考场或扩大混编范围`);
|
||||||
|
const seatNumber = Number(room.seatStart || 1) + seatIndex;
|
||||||
|
const assignment = {
|
||||||
|
registrationId: candidate.id,
|
||||||
|
subjectId: subject.id,
|
||||||
|
centerId: center.id,
|
||||||
|
centerName: center.name,
|
||||||
|
roomId: room.id,
|
||||||
|
roomName: room.name,
|
||||||
|
roomCode: room.code,
|
||||||
|
examRoomCode: '',
|
||||||
|
seat: String(seatNumber).padStart(2, '0'),
|
||||||
|
subjectSignature: candidate.signature
|
||||||
|
};
|
||||||
|
assignments.get(candidate.id).push(assignment);
|
||||||
|
roomUses.push({ subjectId: subject.id, subjectOrder: subjectOrder.get(subject.id), centerCode: center.code, roomCode: room.code, roomId: room.id });
|
||||||
|
seatIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const examRoomCodes = new Map([...new Map(roomUses.map(item => [`${item.subjectId}:${item.roomId}`, item])).values()]
|
||||||
|
.sort((left, right) => left.subjectOrder - right.subjectOrder || left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode))
|
||||||
|
.map((item, index) => [`${item.subjectId}:${item.roomId}`, String(index + 1).padStart(3, '0')]));
|
||||||
|
for (const subjectAssignments of assignments.values()) {
|
||||||
|
subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId));
|
||||||
|
subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(`${item.subjectId}:${item.roomId}`); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequenceCounters = new Map();
|
||||||
|
const numbers = new Set();
|
||||||
|
const cards = candidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey) || stableCompare(seed, left, right)).map(candidate => {
|
||||||
|
const center = activeCenters.find(item => item.id === candidate.centerId);
|
||||||
|
const primary = assignments.get(candidate.id)[0];
|
||||||
|
const candidateSchool = db.schools.find(item => item.id === candidate.profile.schoolId);
|
||||||
|
const centerSchool = db.schools.find(item => item.id === center.schoolId);
|
||||||
|
const sources = {
|
||||||
|
district_code: normalizedCode(candidate.profile.districtCode || center.districtCode),
|
||||||
|
center_school_code: normalizedCode(centerSchool?.code),
|
||||||
|
candidate_school_code: normalizedCode(candidateSchool?.code),
|
||||||
|
exam_room_code: primary.examRoomCode,
|
||||||
|
seat: primary.seat
|
||||||
|
};
|
||||||
|
const counterKey = sequenceGroup(rule, sources);
|
||||||
|
const sequence = (sequenceCounters.get(counterKey) || 0) + 1;
|
||||||
|
sequenceCounters.set(counterKey, sequence);
|
||||||
|
const number = formatAdmissionNumber(rule, sources, sequence);
|
||||||
|
if (numbers.has(number)) throw arrangementError(`规则“${rule.name}”生成了重复准考证号 ${number},请检查规则组成`);
|
||||||
|
numbers.add(number);
|
||||||
|
return {
|
||||||
|
registrationId: candidate.id,
|
||||||
|
number,
|
||||||
|
centerId: center.id,
|
||||||
|
testCenter: center.name,
|
||||||
|
generatedAt: options.generatedAt,
|
||||||
|
assignments: assignments.get(candidate.id)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedCenters = new Set(cards.map(card => card.centerId));
|
||||||
|
const sameSchoolCenterCount = candidates.filter(candidate => activeCenters.find(center => center.id === candidate.centerId)?.schoolId === candidate.profile.schoolId).length;
|
||||||
|
const summary = {
|
||||||
|
candidateCount: candidates.length,
|
||||||
|
centerCount: usedCenters.size,
|
||||||
|
subjectAssignmentCount: cards.reduce((sum, card) => sum + card.assignments.length, 0),
|
||||||
|
subjectCombinationCount: new Set(candidates.map(candidate => candidate.signature)).size,
|
||||||
|
sameSchoolCenterCount,
|
||||||
|
sameSchoolCenterRate: Number((sameSchoolCenterCount * 100 / candidates.length).toFixed(1)),
|
||||||
|
reservedSpareRooms: activeCenters.reduce((sum, center) => sum + center.allRooms.filter(room => room.roomType === 'spare' && !center.rooms.includes(room)).length, 0)
|
||||||
|
};
|
||||||
|
return { exam, rule, mixingScope, seed, warnings, summary, cards };
|
||||||
|
}
|
||||||
@@ -316,6 +316,14 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.region-selects { grid-template-columns:1fr; }
|
.region-selects { grid-template-columns:1fr; }
|
||||||
.public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; }
|
.public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admission-card arrangement engine */
|
||||||
|
.arrangement-config { margin-bottom:15px; overflow:hidden; }.arrangement-config .panel-title { height:auto; min-height:74px; }.arrangement-config .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.arrangement-config form { padding:20px; }.arrangement-fields { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }.arrangement-fields label { display:grid; align-content:start; gap:6px; }.arrangement-fields label > span { color:#59657c; font-size:8px; font-weight:700; }.arrangement-fields select,.arrangement-fields input { width:100%; min-height:42px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:#fff; font-size:9px; }.arrangement-fields label small { color:#9199a9; font-size:7px; line-height:1.6; }.arrangement-actions { display:flex; justify-content:flex-end; gap:9px; margin-top:16px; }.arrangement-preview { display:grid; gap:5px; margin:0 20px 20px; padding:13px 15px; border-radius:9px; }.arrangement-preview.ok { color:#245f4c; background:#e7f4ef; }.arrangement-preview.warning { color:#795a27; background:#fff3dc; }.arrangement-preview strong { font-size:9px; }.arrangement-preview span,.arrangement-preview small,.arrangement-preview li { font-size:8px; }.arrangement-preview ul { margin:5px 0 0; padding-left:18px; }
|
||||||
|
.arrangement-rules { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:15px; }.arrangement-rules article { display:grid; align-content:start; gap:6px; min-height:155px; padding:16px; border:1px solid var(--line); border-radius:11px; background:linear-gradient(145deg,#fff,#f5f7fb); }.arrangement-rules span { color:#8792a9; font-size:7px; }.arrangement-rules h3 { margin:0; font-family:"STKaiti"; font-size:16px; font-weight:400; }.arrangement-rules p { margin:0; color:#818b9f; font-size:7px; line-height:1.7; }.arrangement-rules code { margin-top:auto; color:var(--blue); font-size:10px; }
|
||||||
|
.arrangement-plan-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-bottom:15px; }.arrangement-plan-grid article { padding:17px; }.arrangement-plan-grid header { display:flex; justify-content:space-between; gap:10px; }.arrangement-plan-grid header span,.arrangement-plan-grid time { color:#8691a6; font-size:7px; }.arrangement-plan-grid h3 { margin:4px 0 0; font-size:12px; }.arrangement-plan-grid article > div { display:grid; grid-template-columns:auto 1fr auto 1fr auto 1fr; align-items:baseline; gap:5px; margin:15px 0; }.arrangement-plan-grid article > div strong { color:var(--navy); font-family:Georgia,serif; font-size:20px; font-weight:400; }.arrangement-plan-grid article > div small,.arrangement-plan-grid p,.arrangement-plan-grid li { color:#858fa1; font-size:7px; }.arrangement-plan-grid ul { margin:8px 0 0; padding-left:17px; }.arrangement-ok { color:#2c755c !important; }
|
||||||
|
.subject-room-list,.admit-subject-rooms { display:grid; gap:4px; }.subject-room-list span,.admit-subject-rooms span { display:grid; grid-template-columns:minmax(45px,auto) 1fr; gap:7px; }.subject-room-list b,.admit-subject-rooms b { font-size:8px; }.subject-room-list small,.admit-subject-rooms small { color:#7f899c; font-size:7px; }.admit-preview table { width:100%; margin-top:16px; border-collapse:collapse; }.admit-preview th,.admit-preview td { padding:8px; border:1px solid var(--line); font-size:8px; text-align:left; }
|
||||||
|
@media (max-width: 1000px) { .arrangement-fields,.arrangement-rules { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||||
|
@media (max-width: 620px) { .arrangement-fields,.arrangement-rules,.arrangement-plan-grid { grid-template-columns:1fr; }.arrangement-actions { align-items:stretch; flex-direction:column; } }
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.exam-score-band { grid-template-columns:1fr; }.exam-score-band > span + span { border-top:1px solid rgba(255,255,255,.12); border-left:0; }
|
.exam-score-band { grid-template-columns:1fr; }.exam-score-band > span + span { border-top:1px solid rgba(255,255,255,.12); border-left:0; }
|
||||||
.exam-form-section { padding:13px; }.exam-form-section > header { align-items:flex-start; }.exam-form-section > header aside { display:grid; gap:2px; }
|
.exam-form-section { padding:13px; }.exam-form-section > header { align-items:flex-start; }.exam-form-section > header aside { display:grid; gap:2px; }
|
||||||
|
|||||||
+36
-4
@@ -286,7 +286,11 @@ try {
|
|||||||
registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(),
|
registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(),
|
||||||
examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(),
|
examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(),
|
||||||
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
|
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
|
||||||
location: '测试考点', status: 'published', subjects: ['语文', '数学', '外语']
|
location: '测试考点', status: 'published', subjects: [
|
||||||
|
{ name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passScore: 90 },
|
||||||
|
{ name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 },
|
||||||
|
{ name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passScore: 90 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert.equal(createExam.response.status, 201);
|
assert.equal(createExam.response.status, 201);
|
||||||
@@ -379,8 +383,32 @@ try {
|
|||||||
assert.equal(approveRegistration.data.registration.status, 'approved');
|
assert.equal(approveRegistration.data.registration.status, 'approved');
|
||||||
assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号');
|
assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号');
|
||||||
|
|
||||||
const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
const arrangementContext = await admin.request('/api/admin/admission-arrangements');
|
||||||
assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号');
|
assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则');
|
||||||
|
assert.deepEqual(arrangementContext.data.mixingScopes.map(item => item.code), ['class', 'school', 'district', 'city', 'province'], '应支持班、校、县区、市、省五级混编');
|
||||||
|
for (const scope of arrangementContext.data.mixingScopes) {
|
||||||
|
const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: {
|
||||||
|
mixingScope: scope.code, numberRuleId: arrangementContext.data.rules[0].id, seed: `scope-${scope.code}`
|
||||||
|
} });
|
||||||
|
assert.equal(preview.response.status, 200, `${scope.name}应通过容量与同考点约束预检`);
|
||||||
|
}
|
||||||
|
for (const rule of arrangementContext.data.rules) {
|
||||||
|
const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: {
|
||||||
|
mixingScope: 'school', numberRuleId: rule.id, seed: `rule-${rule.code}`
|
||||||
|
} });
|
||||||
|
assert.equal(preview.response.status, 200, `${rule.name}应能生成有效号码`);
|
||||||
|
assert.equal(new Set(preview.data.samples.map(item => item.number)).size, preview.data.samples.length, `${rule.name}生成的样例号码不得重复`);
|
||||||
|
}
|
||||||
|
const arrangementBody = { mixingScope: 'school', numberRuleId: 'admit_rule_district_room_seat', seed: 'system-test-stable' };
|
||||||
|
const arrangementPreview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: arrangementBody });
|
||||||
|
assert.equal(arrangementPreview.response.status, 200, '正式写入前应可预检整场编排');
|
||||||
|
assert.ok(arrangementPreview.data.summary.subjectAssignmentCount >= arrangementPreview.data.summary.candidateCount, '预检应统计全部科次座位');
|
||||||
|
const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody });
|
||||||
|
const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId);
|
||||||
|
assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号');
|
||||||
|
assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点');
|
||||||
|
assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位');
|
||||||
|
assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用');
|
||||||
|
|
||||||
const candidateRegistrations = await candidate.request('/api/candidate/registrations');
|
const candidateRegistrations = await candidate.request('/api/candidate/registrations');
|
||||||
const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId);
|
const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId);
|
||||||
@@ -409,7 +437,10 @@ try {
|
|||||||
await futureSchoolClient.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } });
|
await futureSchoolClient.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } });
|
||||||
const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
||||||
assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号');
|
assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号');
|
||||||
await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
const futureArrangement = await admin.request(`/api/admin/exams/${futureExam.id}/admission-arrangement`, { method: 'POST', body: {
|
||||||
|
mixingScope: 'district', numberRuleId: 'admit_rule_candidate_school_room_seat', seed: 'future-window-test'
|
||||||
|
} });
|
||||||
|
assert.equal(futureArrangement.response.status, 200, '不同考试应分别保存独立编排方案');
|
||||||
const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`);
|
const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`);
|
||||||
assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载');
|
assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载');
|
||||||
|
|
||||||
@@ -479,6 +510,7 @@ try {
|
|||||||
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
|
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
|
||||||
console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
|
console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
|
||||||
console.log('✓ 多科目考试创建与考生自主选科报名');
|
console.log('✓ 多科目考试创建与考生自主选科报名');
|
||||||
|
console.log('✓ 五级混编、四种号码规则、多科目同考点与逐科座位');
|
||||||
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
|
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
|
||||||
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
|
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
|
||||||
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
||||||
|
|||||||
Reference in New Issue
Block a user