Implement application updates and refinements
This commit is contained in:
@@ -29,16 +29,18 @@
|
||||
|
||||
- 超级、校级、班级三级管理员,同一级支持多个账号
|
||||
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
|
||||
- 校级管理员只管理本校考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
|
||||
- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
|
||||
- 班级管理员只读查看本班考生、成绩和报名状态
|
||||
- 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
|
||||
- 当前处理人可将流程转交给同范围的同级管理员
|
||||
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
|
||||
- 报名号在创建考生账户时只生成一次,后续考试报名自动复用
|
||||
- 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户
|
||||
- 批次结果按班级返回校级管理员,并可导出 CSV 安全下发
|
||||
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态
|
||||
- 批次结果按班级返回校级管理员,并可导出 Excel 安全下发
|
||||
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位编排说明、类型和状态
|
||||
- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案
|
||||
- 班级、班级管理员、报名号班级配额、考生资料、考点考场和成绩均提供 Excel 模板、导入与当前数据导出;只读角色保留对应导出能力
|
||||
- Excel 导入逐行校验并返回具体行号;考生资料和考点考场的批量修改仍必须经过配置好的审批流程
|
||||
- 考务指标与审计日志
|
||||
- 考生资料审核、通过或退回修改
|
||||
- 考试报名及科目审核
|
||||
@@ -60,7 +62,7 @@
|
||||
- 组织、学校、班级三级数据范围在服务端强制过滤
|
||||
- 审批实例、当前责任人、转交和监督操作全程留痕
|
||||
- 桌面端与移动端响应式布局
|
||||
- 零第三方运行时依赖
|
||||
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -73,7 +75,7 @@ npm start
|
||||
|
||||
打开 <http://127.0.0.1:4173>。
|
||||
|
||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v5,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场、批量建号申请与审批结果结构。
|
||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v6,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、学校组织管理、结构化考场、座位编排说明、批量建号申请与审批结果结构。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
@@ -144,7 +146,7 @@ npm start
|
||||
npm test
|
||||
```
|
||||
|
||||
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。
|
||||
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证和成绩完整流程。
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -154,6 +156,7 @@ styles.css 公共首页、考生端、管理端响应式样式
|
||||
app.js 前端路由、状态和业务交互
|
||||
server.mjs HTTP 服务、认证、权限与全部业务 API
|
||||
database.mjs 分表结构、SQLite / MySQL 适配与事务仓储
|
||||
excel.mjs Excel 模板、导入解析与导出工作簿
|
||||
tests/system.test.mjs 端到端系统测试
|
||||
data/exam.sqlite 本地运行后生成的 SQLite 数据库
|
||||
.env.example 开发与生产环境变量模板
|
||||
|
||||
@@ -57,11 +57,12 @@ function money(value) {
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
...options,
|
||||
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body
|
||||
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
|
||||
});
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
@@ -150,7 +151,7 @@ function adminNavForUser() {
|
||||
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||
if (level === 'class') return core;
|
||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||
if (level === 'school') return [core[0], ['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]];
|
||||
}
|
||||
|
||||
@@ -166,6 +167,7 @@ function portalHeadingAction(role, page) {
|
||||
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
|
||||
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
|
||||
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
|
||||
if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
|
||||
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
|
||||
return '';
|
||||
}
|
||||
@@ -273,6 +275,7 @@ async function renderAdmin(page) {
|
||||
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||
organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'],
|
||||
'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'],
|
||||
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
|
||||
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
|
||||
@@ -282,13 +285,13 @@ async function renderAdmin(page) {
|
||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page;
|
||||
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 data = await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
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),
|
||||
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(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)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||
@@ -301,9 +304,19 @@ function adminDashboard(data) {
|
||||
return `<section class="scope-banner"><span>${statusLabels[state.user.adminLevel]}</span><div><strong>${h(data.scopeLabel)}</strong><small>所有指标均已按当前管理员的数据范围过滤</small></div></section><div class="admin-metrics"><article><span>${icons.users}</span><div><small>范围内考生</small><strong>${m.candidates}</strong><em>${m.pendingCandidates} 人待审核</em></div></article><article><span>${icons.check}</span><div><small>考试报名</small><strong>${m.registrations}</strong><em>${m.pendingRegistrations} 条待审核</em></div></article><article><span>${icons.exam}</span><div><small>待处理流程</small><strong>${m.pendingFlows ?? 0}</strong><em>${canFlow ? '进入流程中心办理' : '班级账号只读'}</em></div></article><article><span>${icons.chart}</span><div><small>已发布考试</small><strong>${m.publishedExams}</strong><em>全平台考试计划</em></div></article></div><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>${canFlow ? '当前工作入口' : '本班查询入口'}</h2><span>${h(data.scopeLabel)}</span></div><button data-route="admin/candidates"><i class="urgent">${m.pendingCandidates}</i><span><strong>${state.user.adminLevel === 'class' ? '查看本班考生' : '考生资料流程'}</strong><small>身份、学籍与联系方式</small></span>${icons.arrow}</button><button data-route="admin/registrations"><i>${m.pendingRegistrations}</i><span><strong>${state.user.adminLevel === 'class' ? '查看报名状态' : '考试报名流程'}</strong><small>考试、科目和报名号</small></span>${icons.arrow}</button>${canFlow ? `<button data-route="admin/flows"><i>${m.pendingFlows ?? 0}</i><span><strong>流程中心</strong><small>处理、转交与监督审批</small></span>${icons.arrow}</button>` : ''}<button data-route="admin/results"><i>成</i><span><strong>${state.user.adminLevel === 'super' ? '录入与发布成绩' : '查看范围内成绩'}</strong><small>成绩可见范围由权限控制</small></span>${icons.arrow}</button></section><section class="panel audit-feed"><div class="panel-title"><h2>最近操作</h2><span>系统审计日志</span></div>${data.logs.map(log => `<div><span class="user-avatar">${h((log.actorName || '系').slice(0,1))}</span><p><strong>${h(log.actorName || '系统')} · ${h(log.action)}</strong><small>${h(log.detail)}</small></p><time>${formatDate(log.createdAt,true)}</time></div>`).join('') || '<p class="empty-state">当前账号暂无操作记录</p>'}</section></div>`;
|
||||
}
|
||||
|
||||
function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
|
||||
return `<div class="excel-toolbar"><span><strong>${h(label)} Excel</strong><small>使用系统模板可获得逐行校验</small></span><div>${template ? `<button class="row-action" data-action="excel-download" data-resource="${h(resource)}" data-template="1">下载模板</button>` : ''}<button class="row-action" data-action="excel-download" data-resource="${h(resource)}">导出当前数据</button>${importable ? `<button class="row-action primary" data-action="excel-import" data-resource="${h(resource)}">导入 Excel</button><input type="file" accept=".xlsx" hidden data-excel-file="${h(resource)}">` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
function adminSchoolOrganization(data) {
|
||||
const classes = data.classes || [];
|
||||
const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0);
|
||||
return `<section class="school-org-banner"><div><span>SCHOOL ORGANIZATION</span><h2>${h(data.school?.name)}</h2><p>班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。</p></div><dl><div><dt>班级</dt><dd>${classes.length}</dd></div><div><dt>班级管理员</dt><dd>${activeAdmins}</dd></div><div><dt>在册考生</dt><dd>${classes.reduce((sum, item) => sum + item.candidateCount, 0)}</dd></div></dl></section>${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}<section class="org-class-grid">${classes.map(item => `<article class="panel org-class-card ${item.active ? '' : 'inactive'}"><header><div><span>${h(item.grade)}</span><h2>${h(item.name)}</h2></div>${badge(item.active ? 'approved' : 'closed')}</header><div class="org-class-metrics"><span><strong>${item.candidateCount}</strong><small>在册考生</small></span><span><strong>${item.admins.length}</strong><small>管理员</small></span></div><section><div class="org-admin-head"><strong>班级管理员</strong><button class="row-action" data-action="new-class-admin" data-class-id="${h(item.id)}">添加管理员</button></div>${item.admins.map(admin => `<button class="org-admin-row" data-action="edit-class-admin" data-id="${h(admin.id)}" data-class-id="${h(item.id)}"><span class="user-avatar">${h(admin.displayName.slice(0,1))}</span><span><strong>${h(admin.displayName)}</strong><small class="mono">${h(admin.username)}</small></span>${badge(admin.active ? 'approved' : 'closed')}</button>`).join('') || '<p class="org-empty">尚未配置班级管理员</p>'}</section><footer><button class="row-action" data-action="edit-school-class" data-id="${h(item.id)}">编辑班级</button><button class="row-action" data-action="toggle-school-class" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用班级' : '重新启用'}</button></footer></article>`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}</section>`;
|
||||
}
|
||||
|
||||
function adminCandidates(candidates) {
|
||||
const readOnly = state.user.adminLevel === 'class';
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户进度</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户进度</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminRegistrations(registrations) {
|
||||
@@ -326,7 +339,7 @@ function adminAdmit(registrations) {
|
||||
|
||||
function adminResults(data) {
|
||||
const entry = state.user.adminLevel === 'super' ? `<section class="panel result-entry"><div class="panel-title"><h2>录入单科成绩</h2><span>保存后可立即发布</span></div><form data-form="result-entry"><label><span>报名记录</span><select name="registrationId" data-action="result-registration" required><option value="">请选择考生和考试</option>${data.registrations.map(reg => `<option value="${h(reg.id)}" data-subjects='${h(JSON.stringify(reg.subjects.map(subject => ({id:subject.id,name:subject.name}))))}'>${h(reg.exam.name)} · ${h(reg.registrationNumber || reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span>成绩(0—150)</span><input type="number" name="score" min="0" max="150" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将自动计算"></label></div><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section>` : '';
|
||||
return `<div class="results-admin-grid ${state.user.adminLevel === 'super' ? '' : 'read-only'}">${entry}<section class="panel published-results"><div class="panel-title"><h2>${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 50).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>`;
|
||||
return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}<div class="results-admin-grid ${state.user.adminLevel === 'super' ? '' : 'read-only'}">${entry}<section class="panel published-results"><div class="panel-title"><h2>${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 50).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>`;
|
||||
}
|
||||
|
||||
function adminUsers(data) {
|
||||
@@ -335,9 +348,9 @@ function adminUsers(data) {
|
||||
|
||||
function adminCenters(data) {
|
||||
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
|
||||
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位号</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td class="mono">${h(room.seatStart)}—${h(room.seatEnd)}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||
const requests = data.changeRequests || [];
|
||||
return `<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||||
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
const numberSegmentMeta = {
|
||||
@@ -350,10 +363,10 @@ function adminAccountBatches(data) {
|
||||
const batches = data.batches || [];
|
||||
const form = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
|
||||
const ledger = batches.map(batch => {
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="export-account-batch" data-id="${h(batch.id)}">导出 CSV</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="excel-download" data-resource="account_results" data-batch-id="${h(batch.id)}">导出 Excel</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
|
||||
}).join('');
|
||||
return `${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
}
|
||||
|
||||
function adminNumberRules(data) {
|
||||
@@ -466,6 +479,17 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'new-notice') return openNoticeForm();
|
||||
if (action === 'new-exam') return openExamForm();
|
||||
if (action === 'new-admin') return openAdminForm();
|
||||
if (action === 'new-school-class') return openSchoolClassForm();
|
||||
if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id));
|
||||
if (action === 'toggle-school-class') {
|
||||
await api(`/api/admin/classes/${target.dataset.id}`, { method: 'PATCH', body: { active: target.dataset.active === 'true' } });
|
||||
toast(target.dataset.active === 'true' ? '班级已启用' : '班级已停用', '班级管理员和历史数据仍会保留'); return renderRoute();
|
||||
}
|
||||
if (action === 'new-class-admin') return openClassAdminForm(null, target.dataset.classId);
|
||||
if (action === 'edit-class-admin') {
|
||||
const schoolClass = state.pageData.classes.find(item => item.id === target.dataset.classId);
|
||||
return openClassAdminForm(schoolClass?.admins.find(item => item.id === target.dataset.id), target.dataset.classId);
|
||||
}
|
||||
if (action === 'new-center') return openCenterForm();
|
||||
if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id));
|
||||
if (action === 'add-center-room') {
|
||||
@@ -491,16 +515,16 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
|
||||
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
|
||||
if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
|
||||
if (action === 'export-account-batch') {
|
||||
const batch = state.pageData.batches.find(item => item.id === target.dataset.id);
|
||||
if (!batch?.items?.length) throw new Error('该批次尚无可导出的账号结果');
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const rows = [['序号', '班级', '固定报名号', '初始密码'], ...batch.items.map((item, index) => [index + 1, item.className, item.candidateNumber, item.initialPassword])];
|
||||
const blob = new Blob([`\uFEFF${rows.map(row => row.map(quote).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a'); link.href = url; link.download = `${batch.schoolName}-报名号批次-${batch.id}.csv`; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
return toast('账号清单已导出', '请通过安全渠道向考生下发初始密码');
|
||||
if (action === 'excel-download') {
|
||||
const query = new URLSearchParams();
|
||||
if (target.dataset.template === '1') query.set('template', '1');
|
||||
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
|
||||
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
|
||||
return;
|
||||
}
|
||||
if (action === 'excel-import') {
|
||||
document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click();
|
||||
return;
|
||||
}
|
||||
if (action === 'generate-admit') {
|
||||
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
||||
@@ -541,6 +565,29 @@ document.addEventListener('input', event => {
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches('[data-excel-file]')) {
|
||||
const input = event.target;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const resource = input.dataset.excelFile;
|
||||
input.value = '';
|
||||
(async () => {
|
||||
try {
|
||||
toast('正在导入 Excel', `${file.name} · 逐行校验并写入,错误会标出具体行`);
|
||||
const result = await api(`/api/admin/excel/${resource}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer()
|
||||
});
|
||||
if (resource === 'account_quotas') {
|
||||
document.querySelectorAll('[data-class-id]').forEach(field => { field.value = '0'; });
|
||||
result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; });
|
||||
toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`);
|
||||
} else {
|
||||
toast('Excel 导入完成', `已处理 ${result.count} 条数据`); renderRoute();
|
||||
}
|
||||
} catch (error) { toast('Excel 导入失败', error.message); }
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (event.target.matches('[data-action="school-select"]')) {
|
||||
const form = event.target.closest('form');
|
||||
const classSelect = form?.querySelector('select[name="classId"]');
|
||||
@@ -603,6 +650,15 @@ document.addEventListener('submit', async event => {
|
||||
const body = formObject(form);
|
||||
await api('/api/admin/admins', { method: 'POST', body });
|
||||
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
|
||||
} else if (kind === 'school-class') {
|
||||
const body = formObject(form); body.active = form.active.checked;
|
||||
await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body });
|
||||
closeModal(); toast(body.id ? '班级已更新' : '班级已创建', `${body.grade} · ${body.name}`); renderRoute();
|
||||
} else if (kind === 'class-admin') {
|
||||
const body = formObject(form); body.active = form.active.checked;
|
||||
if (body.id) await api(`/api/admin/admins/${body.id}`, { method: 'PATCH', body });
|
||||
else await api('/api/admin/admins', { method: 'POST', body: { ...body, adminLevel: 'class', schoolId: state.user.schoolId } });
|
||||
closeModal(); toast(body.id ? '班级管理员已更新' : '班级管理员已创建', '权限范围已绑定到指定班级'); renderRoute();
|
||||
} else if (kind === 'candidate-account-batch') {
|
||||
const quotas = [...form.querySelectorAll('[data-class-id]')].map(input => ({ classId: input.dataset.classId, count: Number(input.value || 0) })).filter(item => item.count > 0);
|
||||
const total = quotas.reduce((sum, item) => sum + item.count, 0);
|
||||
@@ -623,8 +679,7 @@ document.addEventListener('submit', async event => {
|
||||
building: row.querySelector('[name="roomBuilding"]').value,
|
||||
floor: row.querySelector('[name="roomFloor"]').value,
|
||||
capacity: Number(row.querySelector('[name="roomCapacity"]').value),
|
||||
seatStart: Number(row.querySelector('[name="roomSeatStart"]').value),
|
||||
seatEnd: Number(row.querySelector('[name="roomSeatEnd"]').value),
|
||||
seatPlan: row.querySelector('[name="roomSeatPlan"]').value,
|
||||
roomType: row.querySelector('[name="roomType"]').value,
|
||||
status: row.querySelector('[name="roomStatus"]').value,
|
||||
notes: row.querySelector('[name="roomNotes"]').value
|
||||
@@ -688,14 +743,23 @@ function openAdminForm() {
|
||||
setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`);
|
||||
}
|
||||
|
||||
function openSchoolClassForm(schoolClass = null) {
|
||||
setModal(`<div class="modal-head"><div><span>SCHOOL CLASS</span><h2>${schoolClass ? '编辑本校班级' : '新增本校班级'}</h2><p>班级是考生资料、报名和成绩权限的最小范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-class">${schoolClass ? `<input type="hidden" name="id" value="${h(schoolClass.id)}">` : ''}<div class="field-row"><label><span>年级 *</span><input name="grade" required value="${h(schoolClass?.grade || '')}" placeholder="例如 高三"></label><label><span>班级名称 *</span><input name="name" required value="${h(schoolClass?.name || '')}" placeholder="例如 高三(3)班"></label></div><label class="agreement"><input type="checkbox" name="active" ${schoolClass?.active === false ? '' : 'checked'}><span>启用该班级,可继续分配考生和班级管理员</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${schoolClass ? '保存班级' : '创建班级'}</button></div></form>`);
|
||||
}
|
||||
|
||||
function openClassAdminForm(admin = null, classId = '') {
|
||||
const classes = state.pageData.classes || [];
|
||||
setModal(`<div class="modal-head"><div><span>CLASS SCOPE</span><h2>${admin ? '维护班级管理员' : '添加班级管理员'}</h2><p>该账号只能查看所绑定班级的考生、成绩和报名状态。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="class-admin">${admin ? `<input type="hidden" name="id" value="${h(admin.id)}">` : ''}<div class="field-row"><label><span>管理员姓名 *</span><input name="displayName" required value="${h(admin?.displayName || '')}"></label><label><span>绑定班级 *</span><select name="classId" required>${classes.map(item => `<option value="${h(item.id)}" ${item.id === (admin?.classId || classId) ? 'selected' : ''}>${h(item.grade)} · ${h(item.name)}</option>`).join('')}</select></label></div>${admin ? `<label><span>登录账号</span><input value="${h(admin.username)}" disabled></label>` : `<label><span>登录账号 *</span><input name="username" required placeholder="建议使用学校代码与班级缩写"></label>`}<label><span>${admin ? '重置密码(留空则不修改)' : '初始密码 *'}</span><input name="password" type="password" ${admin ? '' : 'required minlength="8"'} placeholder="至少 8 位"></label><label class="agreement"><input type="checkbox" name="active" ${admin?.active === false ? '' : 'checked'}><span>启用该管理员账号</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${admin ? '保存管理员' : '创建管理员'}</button></div></form>`);
|
||||
}
|
||||
|
||||
function centerRoomEditor(room = {}) {
|
||||
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label><span>座位起号 *</span><input name="roomSeatStart" type="number" min="1" required value="${h(room.seatStart || 1)}"></label><label><span>座位止号 *</span><input name="roomSeatEnd" type="number" min="1" required value="${h(room.seatEnd || 30)}"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
|
||||
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label class="room-notes"><span>座位编排说明</span><input name="roomSeatPlan" value="${h(room.seatPlan || '')}" placeholder="如:按现场桌贴从前至后编排"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
|
||||
}
|
||||
|
||||
function openCenterForm(center = null) {
|
||||
const schools = state.pageData.schools || [];
|
||||
const rooms = center?.rooms?.length ? center.rooms : [{}];
|
||||
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位区间与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">提交审批</button></div></form>`);
|
||||
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位编排说明与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">提交审批</button></div></form>`);
|
||||
}
|
||||
|
||||
function openFlowDetail(id) {
|
||||
@@ -710,7 +774,7 @@ function openFlowDetail(id) {
|
||||
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName;
|
||||
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
||||
const change = instance.centerChange;
|
||||
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatStart)}—${h(room.seatEnd)}</small></span>`).join('')}</div></section>` : '';
|
||||
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
|
||||
const batch = instance.accountBatch;
|
||||
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
||||
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}${batchSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">${finalBatchStep ? '最终批准并生成账号' : '确认处理'}</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||||
|
||||
+53
-15
@@ -219,6 +219,7 @@ const sqliteSchema = `
|
||||
building TEXT NOT NULL,
|
||||
floor TEXT,
|
||||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||||
seat_plan TEXT,
|
||||
seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0),
|
||||
seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start),
|
||||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||||
@@ -259,6 +260,7 @@ const sqliteSchema = `
|
||||
building TEXT NOT NULL,
|
||||
floor TEXT,
|
||||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||||
seat_plan TEXT,
|
||||
seat_start INTEGER NOT NULL DEFAULT 1,
|
||||
seat_end INTEGER NOT NULL,
|
||||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||||
@@ -597,6 +599,7 @@ const mysqlSchema = [
|
||||
building VARCHAR(120) NOT NULL,
|
||||
floor VARCHAR(40) NULL,
|
||||
capacity INT UNSIGNED NOT NULL,
|
||||
seat_plan VARCHAR(500) NULL,
|
||||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
seat_end INT UNSIGNED NOT NULL,
|
||||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||||
@@ -643,6 +646,7 @@ const mysqlSchema = [
|
||||
building VARCHAR(120) NOT NULL,
|
||||
floor VARCHAR(40) NULL,
|
||||
capacity INT UNSIGNED NOT NULL,
|
||||
seat_plan VARCHAR(500) NULL,
|
||||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
seat_end INT UNSIGNED NOT NULL,
|
||||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||||
@@ -788,7 +792,7 @@ function buildSeedOperations(state) {
|
||||
const nullable = value => value == null || value === '' ? null : value;
|
||||
|
||||
add(
|
||||
'UPDATE schema_metadata SET schema_version = 5, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
'UPDATE schema_metadata SET schema_version = 6, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||
state.meta?.createdAt || new Date().toISOString()
|
||||
);
|
||||
@@ -918,10 +922,10 @@ function buildSeedOperations(state) {
|
||||
for (const room of state.testRooms) {
|
||||
add(
|
||||
`INSERT INTO test_rooms (
|
||||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity),
|
||||
Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes)
|
||||
nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, room.status || 'active', nullable(room.notes)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -943,10 +947,10 @@ function buildSeedOperations(state) {
|
||||
for (const room of state.centerChangeRooms) {
|
||||
add(
|
||||
`INSERT INTO center_change_rooms (
|
||||
id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor),
|
||||
Number(room.capacity), Number(room.seatStart || 1), Number(room.seatEnd), room.roomType,
|
||||
Number(room.capacity), nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType,
|
||||
room.status || 'active', nullable(room.notes)
|
||||
);
|
||||
}
|
||||
@@ -1225,6 +1229,7 @@ function stateFromRows(rows) {
|
||||
building: row.building,
|
||||
floor: row.floor || '',
|
||||
capacity: Number(row.capacity),
|
||||
seatPlan: row.seat_plan || '',
|
||||
seatStart: Number(row.seat_start),
|
||||
seatEnd: Number(row.seat_end),
|
||||
roomType: row.room_type,
|
||||
@@ -1262,6 +1267,7 @@ function stateFromRows(rows) {
|
||||
building: row.building,
|
||||
floor: row.floor || '',
|
||||
capacity: Number(row.capacity),
|
||||
seatPlan: row.seat_plan || '',
|
||||
seatStart: Number(row.seat_start),
|
||||
seatEnd: Number(row.seat_end),
|
||||
roomType: row.room_type,
|
||||
@@ -1717,6 +1723,27 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
auditOperation(log)
|
||||
]);
|
||||
},
|
||||
async saveSchoolClass(schoolClass, isNew, log) {
|
||||
const change = isNew
|
||||
? operation(
|
||||
'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)',
|
||||
schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0
|
||||
)
|
||||
: operation(
|
||||
'UPDATE school_classes SET name = ?, grade = ?, active = ? WHERE id = ?',
|
||||
schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0, schoolClass.id
|
||||
);
|
||||
await transaction([change, auditOperation(log)]);
|
||||
},
|
||||
async updateAdmin(user, passwordChanged, log) {
|
||||
const sql = passwordChanged
|
||||
? 'UPDATE users SET display_name = ?, class_id = ?, active = ?, password_hash = ? WHERE id = ?'
|
||||
: 'UPDATE users SET display_name = ?, class_id = ?, active = ? WHERE id = ?';
|
||||
const params = passwordChanged
|
||||
? [user.displayName, optional(user.classId), user.active ? 1 : 0, user.passwordHash, user.id]
|
||||
: [user.displayName, optional(user.classId), user.active ? 1 : 0, user.id];
|
||||
await transaction([operation(sql, ...params), auditOperation(log)]);
|
||||
},
|
||||
async saveTestCenter(center, isNew, log) {
|
||||
const centerOperation = isNew
|
||||
? operation(
|
||||
@@ -1750,10 +1777,10 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
];
|
||||
for (const room of rooms) operations.push(operation(
|
||||
`INSERT INTO center_change_rooms (
|
||||
id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
room.id, request.id, optional(room.roomId), room.code, room.name, room.building, optional(room.floor),
|
||||
Number(room.capacity), Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status,
|
||||
Number(room.capacity), optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status,
|
||||
optional(room.notes)
|
||||
));
|
||||
operations.push(auditOperation(log));
|
||||
@@ -1798,10 +1825,10 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id));
|
||||
for (const room of rooms) operations.push(operation(
|
||||
`INSERT INTO test_rooms (
|
||||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)
|
||||
optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, optional(room.notes)
|
||||
));
|
||||
}
|
||||
operations.push(auditOperation(log));
|
||||
@@ -1933,6 +1960,8 @@ async function createSqliteStore({ path, seed }) {
|
||||
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
||||
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
|
||||
]);
|
||||
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
|
||||
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
|
||||
if (tableExists('workflow_definitions')) {
|
||||
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
|
||||
if (!definitionSql.includes('candidate_account_batch')) {
|
||||
@@ -2134,13 +2163,17 @@ async function createSqliteStore({ path, seed }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
|
||||
}
|
||||
|
||||
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||
const initialState = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 5, ?, ?, ?)
|
||||
VALUES (1, 6, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
@@ -2222,6 +2255,8 @@ async function createMysqlStore({ seed }) {
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL',
|
||||
"ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'",
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL',
|
||||
'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||
'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
|
||||
];
|
||||
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
|
||||
@@ -2395,6 +2430,9 @@ async function createMysqlStore({ seed }) {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 6) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1');
|
||||
}
|
||||
}
|
||||
if (!existing.length) {
|
||||
const initialState = seed();
|
||||
@@ -2403,7 +2441,7 @@ async function createMysqlStore({ seed }) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 5, ?, ?, ?)
|
||||
VALUES (1, 6, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
const resourceSpecs = {
|
||||
classes: {
|
||||
title: '班级台账', sheet: '班级',
|
||||
columns: [
|
||||
['schoolCode', '学校代码*', 16, 'HZ01'], ['grade', '年级*', 14, '高三'],
|
||||
['name', '班级名称*', 22, '高三(3)班'], ['status', '状态*', 12, '启用']
|
||||
],
|
||||
validations: { status: ['启用', '停用'] }
|
||||
},
|
||||
class_admins: {
|
||||
title: '班级管理员台账', sheet: '班级管理员',
|
||||
columns: [
|
||||
['schoolCode', '学校代码*', 16, 'HZ01'], ['className', '班级名称*', 22, '高三(1)班'],
|
||||
['displayName', '管理员姓名*', 18, '张老师'], ['username', '登录账号*', 20, 'hz01_g301'],
|
||||
['initialPassword', '初始密码(新建必填)', 24, 'ChangeMe123!'], ['status', '状态*', 12, '启用']
|
||||
],
|
||||
validations: { status: ['启用', '停用'] }
|
||||
},
|
||||
account_quotas: {
|
||||
title: '批量报名号申领配额', sheet: '班级配额',
|
||||
columns: [['className', '班级名称*', 24, '高三(1)班'], ['count', '申领数量*', 16, 30]],
|
||||
numberColumns: ['count']
|
||||
},
|
||||
account_results: {
|
||||
title: '报名号下发清单', sheet: '账号结果',
|
||||
columns: [
|
||||
['batchId', '批次编号', 30, ''], ['className', '班级', 22, ''],
|
||||
['candidateNumber', '固定报名号', 26, ''], ['initialPassword', '初始密码', 22, '']
|
||||
]
|
||||
},
|
||||
candidates: {
|
||||
title: '考生资料台账', sheet: '考生资料',
|
||||
columns: [
|
||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'],
|
||||
['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'],
|
||||
['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'],
|
||||
['nativePlace', '籍贯', 18, '江苏海州'], ['address', '家庭住址', 32, '海州市清河区示例路 1 号'],
|
||||
['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'],
|
||||
['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'],
|
||||
['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000']
|
||||
],
|
||||
validations: { gender: ['男', '女'] }
|
||||
},
|
||||
centers: {
|
||||
title: '考点考场档案', sheet: '考点考场',
|
||||
columns: [
|
||||
['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'],
|
||||
['centerName', '考点名称*', 24, '第一中学东区考点'], ['address', '详细地址*', 30, '海州市示例路 8 号'],
|
||||
['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'],
|
||||
['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'],
|
||||
['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'],
|
||||
['centerStatus', '考点状态*', 14, '启用'], ['centerNotes', '考点备注', 26, ''],
|
||||
['roomCode', '考场代码*', 16, '001'], ['roomName', '考场名称*', 22, '第 001 考场'],
|
||||
['building', '楼栋*', 18, '教学楼 A'], ['floor', '楼层', 12, '1 层'],
|
||||
['capacity', '容量*', 12, 30], ['seatPlan', '座位编排说明', 28, '按教室现场座次表编排'],
|
||||
['roomType', '考场类型*', 16, '标准考场'], ['roomStatus', '考场状态*', 14, '启用'],
|
||||
['roomNotes', '考场备注', 26, '']
|
||||
],
|
||||
validations: { centerStatus: ['启用', '停用'], roomStatus: ['启用', '停用'], roomType: ['标准考场', '机考考场', '无障碍考场', '备用考场'] },
|
||||
numberColumns: ['capacity']
|
||||
},
|
||||
results: {
|
||||
title: '考试成绩台账', sheet: '成绩',
|
||||
columns: [
|
||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['examCode', '考试代码*', 20, 'EX-2026-AUT'],
|
||||
['subjectName', '科目*', 16, '语文'], ['score', '成绩*', 12, 120],
|
||||
['grade', '等级', 12, 'A'], ['published', '发布状态*', 14, '发布']
|
||||
],
|
||||
validations: { published: ['发布', '不发布'] },
|
||||
numberColumns: ['score']
|
||||
}
|
||||
};
|
||||
|
||||
export function hasExcelResource(resource) {
|
||||
return Boolean(resourceSpecs[resource]);
|
||||
}
|
||||
|
||||
function cellValue(cell) {
|
||||
const value = cell.value;
|
||||
if (value == null) return '';
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === 'object') {
|
||||
if ('text' in value) return String(value.text || '').trim();
|
||||
if ('result' in value) return String(value.result ?? '').trim();
|
||||
}
|
||||
return typeof value === 'number' ? value : String(value).trim();
|
||||
}
|
||||
|
||||
export async function parseWorkbook(resource, buffer) {
|
||||
const spec = resourceSpecs[resource];
|
||||
if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 });
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
try {
|
||||
await workbook.xlsx.load(buffer);
|
||||
} catch {
|
||||
throw Object.assign(new Error('无法读取 Excel 文件,请使用系统下载的 .xlsx 模板'), { status: 400 });
|
||||
}
|
||||
const sheet = workbook.getWorksheet(spec.sheet) || workbook.worksheets[0];
|
||||
if (!sheet) throw Object.assign(new Error('Excel 文件中没有可读取的工作表'), { status: 400 });
|
||||
const headerMap = new Map();
|
||||
sheet.getRow(2).eachCell((cell, col) => headerMap.set(String(cell.value || '').replace(/\*/g, '').trim(), col));
|
||||
const missing = spec.columns.filter(([, label]) => !headerMap.has(label.replace(/\*/g, '').trim()));
|
||||
if (missing.length) throw Object.assign(new Error(`模板列不完整:缺少 ${missing.map(([, label]) => label).join('、')}`), { status: 400 });
|
||||
const rows = [];
|
||||
for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
const item = { __row: rowNumber };
|
||||
let populated = false;
|
||||
for (const [key, label] of spec.columns) {
|
||||
const value = cellValue(row.getCell(headerMap.get(label.replace(/\*/g, '').trim())));
|
||||
item[key] = value;
|
||||
if (value !== '') populated = true;
|
||||
}
|
||||
if (populated) rows.push(item);
|
||||
}
|
||||
if (!rows.length) throw Object.assign(new Error('Excel 中没有可导入的数据行'), { status: 400 });
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function buildWorkbook(resource, rows = [], { template = false, subtitle = '' } = {}) {
|
||||
const spec = resourceSpecs[resource];
|
||||
if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 });
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '衡准考试信息管理系统';
|
||||
workbook.created = new Date();
|
||||
const sheet = workbook.addWorksheet(spec.sheet, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] });
|
||||
const lastColumn = spec.columns.length;
|
||||
sheet.columns = spec.columns.map(([key, , width]) => ({ key, width }));
|
||||
spec.columns.forEach(([key], index) => {
|
||||
sheet.getColumn(index + 1).numFmt = spec.numberColumns?.includes(key) ? '0' : '@';
|
||||
});
|
||||
sheet.mergeCells(1, 1, 1, lastColumn);
|
||||
const titleCell = sheet.getCell(1, 1);
|
||||
titleCell.value = subtitle ? `${spec.title}|${subtitle}` : spec.title;
|
||||
titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } };
|
||||
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||
sheet.getRow(1).height = 34;
|
||||
const header = sheet.getRow(2);
|
||||
header.values = spec.columns.map(([, label]) => label);
|
||||
header.height = 25;
|
||||
header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } };
|
||||
header.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
||||
for (const item of outputRows) {
|
||||
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? ''])));
|
||||
row.height = 23;
|
||||
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
||||
row.alignment = { vertical: 'middle' };
|
||||
row.eachCell(cell => {
|
||||
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
||||
});
|
||||
}
|
||||
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
||||
for (const [key, values] of Object.entries(spec.validations || {})) {
|
||||
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
||||
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
|
||||
sheet.getCell(row, col).dataValidation = { type: 'list', allowBlank: false, formulae: [`"${values.join(',')}"`] };
|
||||
}
|
||||
}
|
||||
const requiredColumns = spec.columns.map(([, label], index) => label.includes('*') ? index + 1 : 0).filter(Boolean);
|
||||
for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } };
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
Generated
+1001
-2
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,14 @@
|
||||
"test": "node tests/system.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
"mysql2": "^3.14.2"
|
||||
},
|
||||
"overrides": {
|
||||
"exceljs": {
|
||||
"uuid": "^11.1.1"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
}
|
||||
|
||||
+283
-17
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, normalize, resolve } from 'node:path';
|
||||
import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
|
||||
import { createDatabase } from './database.mjs';
|
||||
import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const port = Number(process.env.PORT || 4173);
|
||||
@@ -45,7 +46,7 @@ function seedDatabase() {
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
return {
|
||||
meta: { version: 5, createdAt: nowIso() },
|
||||
meta: { version: 6, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: {
|
||||
name: '海州市教育考试中心',
|
||||
@@ -125,11 +126,11 @@ function seedDatabase() {
|
||||
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||
],
|
||||
testRooms: [
|
||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||
],
|
||||
centerChangeRequests: [],
|
||||
centerChangeRooms: [],
|
||||
@@ -195,6 +196,28 @@ async function readJson(request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 });
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 });
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function sendWorkbook(response, buffer, filename) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
'Content-Length': buffer.length,
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
response.end(buffer);
|
||||
}
|
||||
|
||||
function parseCookies(request) {
|
||||
return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
|
||||
const index = part.indexOf('=');
|
||||
@@ -429,17 +452,14 @@ function parseCenterChange(db, body, schoolId, center = null) {
|
||||
const roomName = cleanText(room.name, 80);
|
||||
const building = cleanText(room.building, 80);
|
||||
const capacity = Number(room.capacity);
|
||||
const seatStart = Number(room.seatStart);
|
||||
const seatEnd = Number(room.seatEnd);
|
||||
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) {
|
||||
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 });
|
||||
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) {
|
||||
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 });
|
||||
}
|
||||
if (seatEnd - seatStart + 1 > capacity) throw Object.assign(new Error(`第 ${index + 1} 个考场的座位号数量不能超过考场容量`), { status: 400 });
|
||||
if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 });
|
||||
roomCodes.add(roomCode);
|
||||
return {
|
||||
id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName,
|
||||
building, floor: cleanText(room.floor, 30), capacity, seatStart, seatEnd,
|
||||
building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity,
|
||||
roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard',
|
||||
status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300)
|
||||
};
|
||||
@@ -485,6 +505,180 @@ function logAction(db, user, action, detail) {
|
||||
return log;
|
||||
}
|
||||
|
||||
const excelResourceNames = {
|
||||
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
|
||||
account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账'
|
||||
};
|
||||
|
||||
function excelRowsForResource(db, user, resource, searchParams) {
|
||||
const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId);
|
||||
if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({
|
||||
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用'
|
||||
}));
|
||||
if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({
|
||||
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '',
|
||||
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName,
|
||||
username: item.username, initialPassword: '', status: item.active ? '启用' : '停用'
|
||||
}));
|
||||
if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 }));
|
||||
if (resource === 'account_results') {
|
||||
const batchId = cleanText(searchParams.get('batchId'), 64);
|
||||
const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId));
|
||||
if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 });
|
||||
return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({
|
||||
batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword
|
||||
}));
|
||||
}
|
||||
if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({
|
||||
candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender,
|
||||
idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email,
|
||||
nativePlace: profile.nativePlace, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
|
||||
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
|
||||
}));
|
||||
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
|
||||
const rooms = db.testRooms.filter(room => room.centerId === center.id);
|
||||
return (rooms.length ? rooms : [{}]).map(room => ({
|
||||
schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name,
|
||||
address: center.address, managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact,
|
||||
emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport,
|
||||
centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes,
|
||||
roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '',
|
||||
seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '',
|
||||
roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || ''
|
||||
}));
|
||||
});
|
||||
if (resource === 'results') {
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||
return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: exam?.subjects.find(item => item.id === result.subjectId)?.name || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' };
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function excelImportError(row, message) {
|
||||
return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 });
|
||||
}
|
||||
|
||||
async function importExcelResource(db, user, resource, rows) {
|
||||
if (resource === 'classes') {
|
||||
if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 });
|
||||
for (const row of rows) {
|
||||
const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
|
||||
if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内');
|
||||
const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60);
|
||||
if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空');
|
||||
const existing = db.classes.find(item => item.schoolId === school.id && item.name === name);
|
||||
const schoolClass = existing || { id: uid('class'), schoolId: school.id };
|
||||
Object.assign(schoolClass, { name, grade, active: row.status !== '停用' });
|
||||
await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`));
|
||||
if (!existing) db.classes.push(schoolClass);
|
||||
}
|
||||
return { count: rows.length };
|
||||
}
|
||||
if (resource === 'class_admins') {
|
||||
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 });
|
||||
for (const row of rows) {
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
|
||||
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100));
|
||||
if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效');
|
||||
const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || '');
|
||||
if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空');
|
||||
const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase());
|
||||
if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用');
|
||||
if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位');
|
||||
if (existing) {
|
||||
Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' });
|
||||
if (password) existing.passwordHash = hashPassword(password);
|
||||
await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`));
|
||||
} else {
|
||||
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() };
|
||||
await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`));
|
||||
db.users.push(created);
|
||||
}
|
||||
}
|
||||
return { count: rows.length };
|
||||
}
|
||||
if (resource === 'account_quotas') {
|
||||
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 });
|
||||
const quotas = rows.filter(row => Number(row.count) > 0).map(row => {
|
||||
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active);
|
||||
if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间');
|
||||
return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) };
|
||||
});
|
||||
if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 });
|
||||
return { count: quotas.length, quotas };
|
||||
}
|
||||
if (resource === 'candidates') {
|
||||
if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 });
|
||||
for (const row of rows) {
|
||||
const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120));
|
||||
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
|
||||
if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内');
|
||||
if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程');
|
||||
const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100));
|
||||
if (!schoolClass) throw excelImportError(row, '班级名称无效');
|
||||
const required = ['name', 'gender', 'idNumber', 'phone'];
|
||||
if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填');
|
||||
Object.assign(profile, {
|
||||
name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30),
|
||||
email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), address: cleanText(row.address, 200), classId: schoolClass.id,
|
||||
grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20),
|
||||
guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso()
|
||||
});
|
||||
const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
|
||||
await database.updateCandidateProfile(profile, profile.name, instance, action);
|
||||
}
|
||||
return { count: rows.length };
|
||||
}
|
||||
if (resource === 'centers') {
|
||||
if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 });
|
||||
const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase());
|
||||
for (const [centerCode, centerRows] of groups) {
|
||||
const first = centerRows[0];
|
||||
const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId));
|
||||
if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效');
|
||||
const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode);
|
||||
if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校');
|
||||
if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更');
|
||||
const body = {
|
||||
schoolId: school.id, code: centerCode, name: first.centerName, address: first.address, managerName: first.managerName,
|
||||
managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime,
|
||||
transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes,
|
||||
rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan,
|
||||
roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType,
|
||||
status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes }))
|
||||
};
|
||||
const parsed = parseCenterChange(db, body, school.id, existing || null);
|
||||
const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
|
||||
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id);
|
||||
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`));
|
||||
}
|
||||
return { count: groups.size };
|
||||
}
|
||||
if (resource === 'results') {
|
||||
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 });
|
||||
for (const row of rows) {
|
||||
const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120));
|
||||
const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60));
|
||||
const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved');
|
||||
const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50));
|
||||
const score = Number(row.score);
|
||||
if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > 150) throw excelImportError(row, '报名号、考试、科目或成绩无效');
|
||||
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
|
||||
const isNew = !result;
|
||||
if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id };
|
||||
Object.assign(result, { score, grade: cleanText(row.grade, 10) || (score >= 120 ? 'A' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null });
|
||||
await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`));
|
||||
if (isNew) db.results.push(result);
|
||||
}
|
||||
return { count: rows.length };
|
||||
}
|
||||
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
}
|
||||
@@ -688,9 +882,66 @@ async function handleAdmin(request, response, pathname) {
|
||||
});
|
||||
}
|
||||
|
||||
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/);
|
||||
if (excelMatch && request.method === 'GET') {
|
||||
const resource = excelMatch[1];
|
||||
if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在');
|
||||
if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据');
|
||||
if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场');
|
||||
if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料');
|
||||
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||
const template = requestUrl.searchParams.get('template') === '1';
|
||||
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
||||
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
if (excelMatch && request.method === 'POST') {
|
||||
const resource = excelMatch[1];
|
||||
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
|
||||
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
|
||||
const result = await importExcelResource(db, user, resource, rows);
|
||||
return sendJson(response, 200, { ok: true, ...result });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/school-organization' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
|
||||
const school = db.schools.find(item => item.id === user.schoolId);
|
||||
const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({
|
||||
...item,
|
||||
candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length,
|
||||
admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active }))
|
||||
}));
|
||||
return sendJson(response, 200, { ok: true, school, classes });
|
||||
}
|
||||
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
||||
const body = await readJson(request);
|
||||
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false };
|
||||
await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`));
|
||||
return sendJson(response, 201, { ok: true, schoolClass });
|
||||
}
|
||||
const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/);
|
||||
if (classMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级');
|
||||
const body = await readJson(request);
|
||||
const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 404, '班级不存在');
|
||||
const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) });
|
||||
await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`));
|
||||
return sendJson(response, 200, { ok: true, schoolClass });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admins' && request.method === 'GET') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const admins = db.users.filter(item => item.role === 'admin').map(item => ({
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员');
|
||||
const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).map(item => ({
|
||||
...safeUser(item),
|
||||
active: item.active,
|
||||
levelName: adminLevelNames[item.adminLevel],
|
||||
@@ -700,15 +951,15 @@ async function handleAdmin(request, response, pathname) {
|
||||
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||
}
|
||||
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const username = cleanText(body.username, 50);
|
||||
const password = String(body.password || '');
|
||||
const displayName = cleanText(body.displayName, 50);
|
||||
const adminLevel = cleanText(body.adminLevel, 20);
|
||||
const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20);
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员');
|
||||
if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码');
|
||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
||||
const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64);
|
||||
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
|
||||
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
||||
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
|
||||
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
||||
@@ -717,6 +968,21 @@ async function handleAdmin(request, response, pathname) {
|
||||
await database.createAdmin(created, log);
|
||||
return sendJson(response, 201, { ok: true, admin: safeUser(created) });
|
||||
}
|
||||
const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/);
|
||||
if (adminMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员');
|
||||
const body = await readJson(request);
|
||||
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId);
|
||||
if (!target) return sendError(response, 404, '班级管理员不存在');
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 400, '请选择本校有效班级');
|
||||
const password = String(body.password || '');
|
||||
if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位');
|
||||
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) });
|
||||
if (password) target.passwordHash = hashPassword(password);
|
||||
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`));
|
||||
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
|
||||
}
|
||||
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
|
||||
+37
@@ -14,6 +14,40 @@
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
/* School organization and Excel operations */
|
||||
.excel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin-bottom: 12px; padding: 14px 18px; border: 1px solid #dbe5ea; border-left: 4px solid #2c7b82; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(23,63,96,.05); }
|
||||
.excel-toolbar > span strong, .excel-toolbar > span small { display: block; }
|
||||
.excel-toolbar > span strong { color: #173f60; }
|
||||
.excel-toolbar > span small { margin-top: 3px; color: #788b97; font-size: 11px; }
|
||||
.excel-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.school-org-banner { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 22px; padding: 28px 30px; color: #fff; background: linear-gradient(118deg, #142f4c, #1c526b 70%, #2a7c7c); border-radius: 12px; box-shadow: 0 18px 42px rgba(20,47,76,.16); }
|
||||
.school-org-banner > div > span { color: #8ed8d4; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
|
||||
.school-org-banner h2 { margin: 6px 0; font-family: "STKaiti", "KaiTi", serif; font-size: 28px; }
|
||||
.school-org-banner p { max-width: 620px; margin: 0; color: #c7dce4; line-height: 1.65; }
|
||||
.school-org-banner dl { display: flex; gap: 26px; margin: 0; }
|
||||
.school-org-banner dl div { min-width: 74px; padding-left: 18px; border-left: 1px solid rgba(255,255,255,.22); }
|
||||
.school-org-banner dt { color: #bdd3dc; font-size: 11px; }
|
||||
.school-org-banner dd { margin: 4px 0 0; font: 700 27px Consolas, monospace; }
|
||||
.org-class-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 24px; }
|
||||
.org-class-card { padding: 0; overflow: hidden; }
|
||||
.org-class-card.inactive { opacity: .72; }
|
||||
.org-class-card > header { display: flex; align-items: flex-start; justify-content: space-between; padding: 22px 24px 14px; }
|
||||
.org-class-card > header span:first-child { color: #2c7b82; font-size: 11px; font-weight: 800; letter-spacing: .1em; }
|
||||
.org-class-card h2 { margin: 4px 0 0; color: #173b55; font-family: "STKaiti", "KaiTi", serif; font-size: 23px; }
|
||||
.org-class-metrics { display: flex; gap: 1px; background: #dfe7eb; }
|
||||
.org-class-metrics span { flex: 1; padding: 13px 24px; background: #f7fafb; }
|
||||
.org-class-metrics strong, .org-class-metrics small { display: block; }
|
||||
.org-class-metrics strong { color: #1b5265; font-size: 20px; }
|
||||
.org-class-metrics small { margin-top: 2px; color: #80909a; }
|
||||
.org-class-card > section { padding: 18px 24px; }
|
||||
.org-admin-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.org-admin-head > strong { color: #365266; font-size: 13px; }
|
||||
.org-admin-row { width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 9px 0; border: 0; border-top: 1px solid #e7edef; background: transparent; text-align: left; }
|
||||
.org-admin-row > span:nth-child(2) strong, .org-admin-row > span:nth-child(2) small { display: block; }
|
||||
.org-admin-row > span:nth-child(2) small { margin-top: 2px; color: #81909a; font-size: 10px; }
|
||||
.org-empty { margin: 10px 0 0; color: #8a98a1; font-size: 12px; }
|
||||
.org-class-card > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 24px; border-top: 1px solid #e5ecef; background: #f8fafb; }
|
||||
|
||||
/* School account batch issuance */
|
||||
.batch-quota-panel { overflow: hidden; padding: 0; }
|
||||
.batch-quota-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 28px; padding: 30px 32px 24px; color: #fff; background: linear-gradient(120deg, #102944, #173f60 72%, #276e82); }
|
||||
@@ -75,6 +109,9 @@
|
||||
.flow-batch-snapshot > p { margin: 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.excel-toolbar, .school-org-banner { align-items: stretch; flex-direction: column; }
|
||||
.school-org-banner dl { justify-content: space-between; }
|
||||
.org-class-grid { grid-template-columns: 1fr; }
|
||||
.batch-quota-head, .batch-submit-bar, .ledger-title { align-items: stretch; flex-direction: column; }
|
||||
.batch-quota-head > div:last-child { padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.2); border-left: 0; }
|
||||
.quota-grid { grid-template-columns: 1fr; }
|
||||
|
||||
+58
-5
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process';
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { relationalTables } from '../database.mjs';
|
||||
import { buildWorkbook } from '../excel.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const port = 4182;
|
||||
@@ -36,15 +38,16 @@ function createClient() {
|
||||
let cookie = '';
|
||||
return {
|
||||
async request(path, options = {}) {
|
||||
const binaryBody = Buffer.isBuffer(options.body) || options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body);
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body
|
||||
headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
|
||||
});
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
if (setCookie) cookie = setCookie.split(';')[0];
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
const data = type.includes('application/json') ? await response.json() : type.includes('spreadsheetml') ? Buffer.from(await response.arrayBuffer()) : await response.text();
|
||||
return { response, data };
|
||||
}
|
||||
};
|
||||
@@ -143,7 +146,7 @@ try {
|
||||
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
||||
code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678',
|
||||
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
||||
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }]
|
||||
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }]
|
||||
} });
|
||||
assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库');
|
||||
assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校');
|
||||
@@ -158,7 +161,7 @@ try {
|
||||
const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: {
|
||||
...createdCenter, managerName: '变更后负责人', rooms: [
|
||||
...createdCenter.rooms,
|
||||
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatStart: 31, seatEnd: 55, roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
||||
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatPlan: '无障碍座位现场标记', roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
||||
]
|
||||
} });
|
||||
assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批');
|
||||
@@ -170,6 +173,55 @@ try {
|
||||
assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换');
|
||||
assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点');
|
||||
|
||||
const organization = await schoolAdmin.request('/api/admin/school-organization');
|
||||
assert.equal(organization.response.status, 200, '校级管理员应有本校组织管理入口');
|
||||
assert.ok(organization.data.classes.every(item => item.schoolId === 'school_hz1'), '组织页只能返回本校班级');
|
||||
const createClass = await schoolAdmin.request('/api/admin/classes', { method: 'POST', body: { grade: '高二', name: '高二(9)班', active: true } });
|
||||
assert.equal(createClass.response.status, 201, '校级管理员应能新增本校班级');
|
||||
const newClassId = createClass.data.schoolClass.id;
|
||||
const createClassAdmin = await schoolAdmin.request('/api/admin/admins', { method: 'POST', body: { displayName: '批量测试班管', username: 'class_excel_test', password: 'ClassExcel123!', classId: newClassId } });
|
||||
assert.equal(createClassAdmin.response.status, 201, '校级管理员应能创建并绑定本校班级管理员');
|
||||
assert.equal(createClassAdmin.data.admin.adminLevel, 'class');
|
||||
assert.equal(createClassAdmin.data.admin.schoolId, 'school_hz1');
|
||||
assert.equal((await schoolAdmin.request(`/api/admin/admins/${createClassAdmin.data.admin.id}`, { method: 'PATCH', body: { classId: 'class_hz1_301', active: false } })).response.status, 200, '校级管理员应能调整或停用本校班级管理员');
|
||||
assert.equal((await admin.request('/api/admin/classes', { method: 'POST', body: { grade: '越权', name: '越权班' } })).response.status, 403, '超级管理员不应代替学校维护班级');
|
||||
|
||||
const classTemplate = await schoolAdmin.request('/api/admin/excel/classes?template=1');
|
||||
assert.equal(classTemplate.response.status, 200, '班级 Excel 模板应可下载');
|
||||
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
||||
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
||||
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
||||
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
||||
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
||||
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
||||
assert.equal(classImport.data.count, 1);
|
||||
const quotaFile = Buffer.from(await buildWorkbook('account_quotas', [{ className: '高三(1)班', count: 12 }, { className: '高三(2)班', count: 8 }]));
|
||||
const quotaImport = await schoolAdmin.request('/api/admin/excel/account_quotas', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: quotaFile });
|
||||
assert.deepEqual(quotaImport.data.quotas.map(item => item.count), [12, 8], '报名号配额 Excel 应解析并回填各班数量');
|
||||
const centerTemplate = await schoolAdmin.request('/api/admin/excel/centers?template=1');
|
||||
const centerWorkbook = new ExcelJS.Workbook(); await centerWorkbook.xlsx.load(centerTemplate.data);
|
||||
const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String);
|
||||
assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明');
|
||||
assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号');
|
||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').value, '001', '考场代码应保留前导零');
|
||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').numFmt, '@', '标识号列应固定为文本格式');
|
||||
const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }]));
|
||||
const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile });
|
||||
assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入');
|
||||
const centerImportFile = Buffer.from(await buildWorkbook('centers', [{
|
||||
schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', address: '海州市表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试',
|
||||
roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: ''
|
||||
}]));
|
||||
const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile });
|
||||
assert.equal(centerImport.response.status, 200, '考点考场 Excel 导入应创建审批申请');
|
||||
assert.equal(centerImport.data.count, 1);
|
||||
const excelPendingCenter = (await schoolAdmin.request('/api/admin/centers')).data.changeRequests.find(item => item.code === 'HZ01-XLSX');
|
||||
assert.ok(excelPendingCenter && excelPendingCenter.status === 'pending', 'Excel 导入的考点必须进入审批而非直接生效');
|
||||
assert.equal(excelPendingCenter.rooms[0].seatPlan, '按现场桌贴编排');
|
||||
assert.equal((await schoolAdmin.request('/api/admin/excel/candidates')).response.status, 200, '考生资料应支持按学校范围导出 Excel');
|
||||
assert.equal((await schoolAdmin.request('/api/admin/excel/results')).response.status, 200, '成绩台账应支持按学校范围导出 Excel');
|
||||
assert.equal((await classAdmin.request('/api/admin/excel/centers')).response.status, 403, '班级管理员不得通过 Excel 接口越权导出考场');
|
||||
|
||||
const numberRules = await admin.request('/api/admin/number-rules');
|
||||
assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则');
|
||||
const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: {
|
||||
@@ -359,6 +411,7 @@ try {
|
||||
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
|
||||
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
|
||||
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
||||
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
|
||||
console.log('✓ 成绩录入、发布与考生查询');
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
|
||||
Reference in New Issue
Block a user