流程设计
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
- 查看开放考试并自主选择多个报考科目
|
- 查看开放考试并自主选择多个报考科目
|
||||||
- 查看报名、缴费和审核状态
|
- 查看报名、缴费和审核状态
|
||||||
- 准考证生成状态、开放时间与下载
|
- 准考证生成状态、开放时间与下载
|
||||||
- 已发布成绩查询
|
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
||||||
- 通知公告中心
|
- 通知公告中心
|
||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
@@ -30,8 +30,9 @@
|
|||||||
- 超级、校级、班级三级管理员,同一级支持多个账号
|
- 超级、校级、班级三级管理员,同一级支持多个账号
|
||||||
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
|
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
|
||||||
- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
|
- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
|
||||||
- 班级管理员只读查看本班考生、成绩和报名状态
|
- 班级管理员可查看并审批本班考生、报名与成绩复议流程,成绩录入仍仅限超级管理员
|
||||||
- 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
|
- 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
|
||||||
|
- 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分
|
||||||
- 当前处理人可将流程转交给同范围的同级管理员
|
- 当前处理人可将流程转交给同范围的同级管理员
|
||||||
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
|
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
|
||||||
- 报名号在创建考生账户时只生成一次,后续考试报名自动复用
|
- 报名号在创建考生账户时只生成一次,后续考试报名自动复用
|
||||||
@@ -76,7 +77,7 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v7,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、学校组织管理、结构化考场、座位编排说明、批量建号申请与审批结果,以及考生/考点省市区县代码结构。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v8,不兼容旧数据;升级本分支后请运行 `npm run reset-db` 重建开发库。v8 增加班级审批节点、成绩复议流程、严格校班匹配和多管理员均分。
|
||||||
|
|
||||||
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ npm start
|
|||||||
| 校级管理员 | `school_admin` | `School123!` |
|
| 校级管理员 | `school_admin` | `School123!` |
|
||||||
| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` |
|
| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` |
|
||||||
| 班级管理员 | `class_admin` | `Class123!` |
|
| 班级管理员 | `class_admin` | `Class123!` |
|
||||||
|
| 同班班级管理员(均分演示) | `class_admin_2` | `Class123!` |
|
||||||
| 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` |
|
| 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` |
|
||||||
|
|
||||||
## 自动化测试
|
## 自动化测试
|
||||||
@@ -149,7 +151,7 @@ npm start
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证和成绩完整流程。
|
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证、成绩复议、校班严格匹配和多人均分。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function renderError(error) {
|
|||||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
|
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
|
||||||
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
|
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
|
||||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand });
|
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand });
|
||||||
const { renderAdmin } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser });
|
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser });
|
||||||
|
|
||||||
function navigate(route) {
|
function navigate(route) {
|
||||||
location.hash = route;
|
location.hash = route;
|
||||||
@@ -307,6 +307,10 @@ document.addEventListener('submit', async event => {
|
|||||||
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
|
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
|
||||||
await api('/api/candidate/registrations', { method: 'POST', body });
|
await api('/api/candidate/registrations', { method: 'POST', body });
|
||||||
toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute();
|
toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute();
|
||||||
|
} else if (kind === 'score-appeal') {
|
||||||
|
const body = formObject(form);
|
||||||
|
await api(`/api/candidate/results/${body.resultId}/appeals`, { method: 'POST', body: { reason: body.reason } });
|
||||||
|
toast('成绩复议已提交', '系统已按班级、学校和考试中心流程自动分配'); renderRoute();
|
||||||
} else if (kind === 'candidate-review') {
|
} else if (kind === 'candidate-review') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body });
|
await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body });
|
||||||
@@ -377,7 +381,9 @@ document.addEventListener('submit', async event => {
|
|||||||
? `/api/admin/registrations/${body.businessId}`
|
? `/api/admin/registrations/${body.businessId}`
|
||||||
: body.businessType === 'center_change'
|
: body.businessType === 'center_change'
|
||||||
? `/api/admin/center-change-requests/${body.businessId}`
|
? `/api/admin/center-change-requests/${body.businessId}`
|
||||||
: `/api/admin/candidate-account-batches/${body.businessId}`;
|
: body.businessType === 'candidate_account_batch'
|
||||||
|
? `/api/admin/candidate-account-batches/${body.businessId}`
|
||||||
|
: `/api/admin/score-appeals/${body.businessId}`;
|
||||||
await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } });
|
await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } });
|
||||||
closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute();
|
closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute();
|
||||||
} else if (kind === 'flow-transfer') {
|
} else if (kind === 'flow-transfer') {
|
||||||
@@ -428,7 +434,7 @@ function openSchoolClassForm(schoolClass = null) {
|
|||||||
|
|
||||||
function openClassAdminForm(admin = null, classId = '') {
|
function openClassAdminForm(admin = null, classId = '') {
|
||||||
const classes = state.pageData.classes || [];
|
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>`);
|
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 = {}) {
|
function centerRoomEditor(room = {}) {
|
||||||
@@ -445,14 +451,20 @@ function openCenterForm(center = null) {
|
|||||||
function openFlowDetail(id) {
|
function openFlowDetail(id) {
|
||||||
const instance = state.pageData.instances.find(item => item.id === id);
|
const instance = state.pageData.instances.find(item => item.id === id);
|
||||||
const currentLevel = instance.currentStepDetail?.adminLevel;
|
const currentLevel = instance.currentStepDetail?.adminLevel;
|
||||||
const available = state.pageData.availableAdmins.filter(item => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId));
|
const available = state.pageData.availableAdmins.filter(item => {
|
||||||
const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || instance.assignee?.id === state.user.id);
|
if (item.adminLevel !== currentLevel) return false;
|
||||||
|
if (currentLevel === 'super') return true;
|
||||||
|
if (currentLevel === 'school') return item.schoolId === instance.assignee?.schoolId;
|
||||||
|
return item.schoolId === instance.assignee?.schoolId && item.classId === instance.assignee?.classId;
|
||||||
|
});
|
||||||
|
const canProcess = instance.status === 'pending' && instance.assignee?.id === state.user.id;
|
||||||
const history = instance.actions.map(action => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).join('');
|
const history = instance.actions.map(action => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).join('');
|
||||||
const isCenter = instance.businessType === 'center_change';
|
const isCenter = instance.businessType === 'center_change';
|
||||||
const isBatch = instance.businessType === 'candidate_account_batch';
|
const isBatch = instance.businessType === 'candidate_account_batch';
|
||||||
|
const isAppeal = instance.businessType === 'score_appeal';
|
||||||
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
|
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
|
||||||
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName;
|
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName;
|
||||||
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
||||||
const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null;
|
const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null;
|
||||||
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 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 batch = instance.accountBatch;
|
||||||
@@ -463,13 +475,13 @@ function openFlowDetail(id) {
|
|||||||
function openCandidateReview(id) {
|
function openCandidateReview(id) {
|
||||||
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
||||||
const item = { ...source, address: formatRegionAddress(source) };
|
const item = { ...source, address: formatRegionAddress(source) };
|
||||||
const canReview = state.user.adminLevel !== 'class' && item.status === 'pending';
|
const canReview = item.status === 'pending' && item.workflow?.assignee?.id === state.user.id;
|
||||||
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></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>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></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>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRegistrationReview(id) {
|
function openRegistrationReview(id) {
|
||||||
const reg = state.pageData.registrations.find(item => item.id === id);
|
const reg = state.pageData.registrations.find(item => item.id === id);
|
||||||
const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending';
|
const canReview = reg.status === 'pending' && reg.workflow?.assignee?.id === state.user.id;
|
||||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></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>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></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>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -53,7 +53,7 @@ function buildSeedOperations(state) {
|
|||||||
const nullable = value => value == null || value === '' ? null : value;
|
const nullable = value => value == null || value === '' ? null : value;
|
||||||
|
|
||||||
add(
|
add(
|
||||||
'UPDATE schema_metadata SET schema_version = 7, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
'UPDATE schema_metadata SET schema_version = 8, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||||
state.meta?.createdAt || new Date().toISOString()
|
state.meta?.createdAt || new Date().toISOString()
|
||||||
);
|
);
|
||||||
@@ -974,6 +974,11 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
if (log) operations.push(auditOperation(log));
|
if (log) operations.push(auditOperation(log));
|
||||||
await transaction(operations);
|
await transaction(operations);
|
||||||
},
|
},
|
||||||
|
async createWorkflow(instance, action, log = null) {
|
||||||
|
const operations = workflowCreateOperations(instance, action);
|
||||||
|
if (log) operations.push(auditOperation(log));
|
||||||
|
await transaction(operations);
|
||||||
|
},
|
||||||
async saveWorkflow(workflow, log) {
|
async saveWorkflow(workflow, log) {
|
||||||
const operations = [
|
const operations = [
|
||||||
operation(
|
operation(
|
||||||
|
|||||||
+31
-2
@@ -72,10 +72,33 @@ function adminsForStep(db, adminLevel, profile) {
|
|||||||
if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false;
|
if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false;
|
||||||
if (adminLevel === 'super') return true;
|
if (adminLevel === 'super') return true;
|
||||||
if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId);
|
if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId);
|
||||||
return Boolean(profile?.classId && item.classId === profile.classId);
|
if (adminLevel === 'class') return Boolean(
|
||||||
|
profile?.schoolId && profile?.classId
|
||||||
|
&& item.schoolId === profile.schoolId && item.classId === profile.classId
|
||||||
|
);
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectAdminForStep(db, adminLevel, profile) {
|
||||||
|
const pendingByAdmin = new Map();
|
||||||
|
for (const instance of db.workflowInstances) {
|
||||||
|
if (instance.status !== 'pending' || !instance.assigneeId) continue;
|
||||||
|
pendingByAdmin.set(instance.assigneeId, (pendingByAdmin.get(instance.assigneeId) || 0) + 1);
|
||||||
|
}
|
||||||
|
const assignedByAdmin = new Map();
|
||||||
|
for (const action of db.workflowActions) {
|
||||||
|
if (!action.toAssigneeId) continue;
|
||||||
|
assignedByAdmin.set(action.toAssigneeId, (assignedByAdmin.get(action.toAssigneeId) || 0) + 1);
|
||||||
|
}
|
||||||
|
return adminsForStep(db, adminLevel, profile).sort((left, right) =>
|
||||||
|
(pendingByAdmin.get(left.id) || 0) - (pendingByAdmin.get(right.id) || 0)
|
||||||
|
|| (assignedByAdmin.get(left.id) || 0) - (assignedByAdmin.get(right.id) || 0)
|
||||||
|
|| String(left.createdAt || '').localeCompare(String(right.createdAt || ''))
|
||||||
|
|| left.id.localeCompare(right.id)
|
||||||
|
)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
function activeWorkflow(db, businessType) {
|
function activeWorkflow(db, businessType) {
|
||||||
return db.workflows.find(item => item.businessType === businessType && item.active);
|
return db.workflows.find(item => item.businessType === businessType && item.active);
|
||||||
}
|
}
|
||||||
@@ -84,7 +107,7 @@ function createWorkflowSubmission(db, businessType, businessId, profile, actorId
|
|||||||
const workflow = activeWorkflow(db, businessType);
|
const workflow = activeWorkflow(db, businessType);
|
||||||
if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 });
|
if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 });
|
||||||
const firstStep = workflow.steps[0];
|
const firstStep = workflow.steps[0];
|
||||||
const assignee = adminsForStep(db, firstStep.adminLevel, profile)[0];
|
const assignee = selectAdminForStep(db, firstStep.adminLevel, profile);
|
||||||
if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 });
|
if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 });
|
||||||
const instance = {
|
const instance = {
|
||||||
id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1,
|
id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1,
|
||||||
@@ -165,6 +188,11 @@ function workflowScopeProfile(db, instance) {
|
|||||||
const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId);
|
const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId);
|
||||||
return batch ? centerScopeProfile(db, batch.schoolId) : null;
|
return batch ? centerScopeProfile(db, batch.schoolId) : null;
|
||||||
}
|
}
|
||||||
|
if (instance.businessType === 'score_appeal') {
|
||||||
|
const result = db.results.find(item => item.id === instance.businessId);
|
||||||
|
const registration = db.registrations.find(item => item.id === result?.registrationId);
|
||||||
|
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +564,7 @@ const routeContext = {
|
|||||||
registrationInScope,
|
registrationInScope,
|
||||||
adminScopeLabel,
|
adminScopeLabel,
|
||||||
adminsForStep,
|
adminsForStep,
|
||||||
|
selectAdminForStep,
|
||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
createWorkflowSubmission,
|
createWorkflowSubmission,
|
||||||
workflowView,
|
workflowView,
|
||||||
|
|||||||
+12
-10
@@ -55,7 +55,7 @@ export function createAdminViews(context) {
|
|||||||
|
|
||||||
function adminDashboard(data) {
|
function adminDashboard(data) {
|
||||||
const m = data.metrics;
|
const m = data.metrics;
|
||||||
const canFlow = state.user.adminLevel !== 'class';
|
const canFlow = true;
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +70,12 @@ export function createAdminViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminCandidates(candidates) {
|
function adminCandidates(candidates) {
|
||||||
const readOnly = state.user.adminLevel === 'class';
|
const readOnly = false;
|
||||||
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>`;
|
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) {
|
function adminRegistrations(registrations) {
|
||||||
const readOnly = state.user.adminLevel === 'class';
|
const readOnly = false;
|
||||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试、固定报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试、固定报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,8 @@ export function createAdminViews(context) {
|
|||||||
|
|
||||||
function adminResults(data) {
|
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,fullScore:subject.fullScore,passScore:subject.passScore}))))}'>${h(reg.exam.name)} · ${h(reg.registrationNumber || reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span data-score-label>成绩</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将按得分率计算"></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示满分与单科合格线。</p><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section>` : '';
|
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,fullScore:subject.fullScore,passScore:subject.passScore}))))}'>${h(reg.exam.name)} · ${h(reg.registrationNumber || reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span data-score-label>成绩</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将按得分率计算"></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示满分与单科合格线。</p><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section>` : '';
|
||||||
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>`;
|
const appealLedger = `<section class="panel data-panel"><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>${(data.appeals || []).map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`;
|
||||||
|
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>${appealLedger}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminUsers(data) {
|
function adminUsers(data) {
|
||||||
@@ -135,25 +136,26 @@ export function createAdminViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminFlowDesign(workflows) {
|
function adminFlowDesign(workflows) {
|
||||||
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' };
|
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH', score_appeal: 'SCORE APPEAL' };
|
||||||
return `<div class="workflow-design-grid">${workflows.map(workflow => `<section class="panel workflow-designer"><header><div><span>${h(codes[workflow.businessType] || workflow.businessType)}</span><h2>${h(workflow.name)}</h2></div><button class="row-action" data-action="add-workflow-step" data-type="${h(workflow.businessType)}">添加步骤</button></header><form data-form="workflow-design" data-type="${h(workflow.businessType)}"><input name="name" value="${h(workflow.name)}" required><div class="workflow-step-editor" data-workflow-steps>${workflow.steps.map(step => workflowStepEditor(step)).join('')}</div><button class="solid-button" type="submit">保存流程</button></form></section>`).join('')}</div>`;
|
return `<div class="workflow-design-grid">${workflows.map(workflow => `<section class="panel workflow-designer"><header><div><span>${h(codes[workflow.businessType] || workflow.businessType)}</span><h2>${h(workflow.name)}</h2></div><button class="row-action" data-action="add-workflow-step" data-type="${h(workflow.businessType)}">添加步骤</button></header><form data-form="workflow-design" data-type="${h(workflow.businessType)}"><input name="name" value="${h(workflow.name)}" required><div class="workflow-step-editor" data-workflow-steps>${workflow.steps.map(step => workflowStepEditor(step)).join('')}</div><button class="solid-button" type="submit">保存流程</button></form></section>`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function workflowStepEditor(step = {}) {
|
function workflowStepEditor(step = {}) {
|
||||||
return `<div class="workflow-step-row"><i></i><input name="stepName" value="${h(step.name)}" placeholder="步骤名称" required><select name="stepLevel"><option value="school" ${step.adminLevel === 'school' ? 'selected' : ''}>校级管理员</option><option value="super" ${step.adminLevel === 'super' ? 'selected' : ''}>超级管理员</option></select><button type="button" data-action="remove-workflow-step" aria-label="移除步骤">×</button></div>`;
|
return `<div class="workflow-step-row"><i></i><input name="stepName" value="${h(step.name)}" placeholder="步骤名称" required><select name="stepLevel"><option value="class" ${step.adminLevel === 'class' ? 'selected' : ''}>班级管理员</option><option value="school" ${step.adminLevel === 'school' ? 'selected' : ''}>校级管理员</option><option value="super" ${step.adminLevel === 'super' ? 'selected' : ''}>超级管理员</option></select><button type="button" data-action="remove-workflow-step" aria-label="移除步骤">×</button></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminFlows(data) {
|
function adminFlows(data) {
|
||||||
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
|
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
|
||||||
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领' };
|
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领', score_appeal: '考生成绩复议' };
|
||||||
return `<div class="workflow-board">${data.instances.map(instance => {
|
return `<div class="workflow-board">${data.instances.map(instance => {
|
||||||
const isCenter = instance.businessType === 'center_change';
|
const isCenter = instance.businessType === 'center_change';
|
||||||
const isBatch = instance.businessType === 'candidate_account_batch';
|
const isBatch = instance.businessType === 'candidate_account_batch';
|
||||||
const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName;
|
const isAppeal = instance.businessType === 'score_appeal';
|
||||||
const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`;
|
const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName;
|
||||||
|
const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`;
|
||||||
return `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-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 < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`;
|
return `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-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 < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`;
|
||||||
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}</div>`;
|
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { renderAdmin };
|
return { renderAdmin, workflowStepEditor };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function createCandidateViews(context) {
|
|||||||
function adminNavForUser() {
|
function adminNavForUser() {
|
||||||
const level = state.user?.adminLevel || 'super';
|
const level = state.user?.adminLevel || 'super';
|
||||||
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||||
if (level === 'class') return core;
|
if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]];
|
||||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
||||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
||||||
@@ -35,7 +35,7 @@ export function createCandidateViews(context) {
|
|||||||
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||||
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
||||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' && state.user?.adminLevel !== 'class' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function portalHeadingAction(role, page) {
|
function portalHeadingAction(role, page) {
|
||||||
@@ -138,7 +138,22 @@ export function createCandidateViews(context) {
|
|||||||
const { results, summaries = [] } = data;
|
const { results, summaries = [] } = data;
|
||||||
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
||||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
||||||
return `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => { const summary = summaries.find(item => item.examId === items[0].examId); const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线'; const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `第 ${summary.rank} / ${summary.cohortSize} 名` : summary ? `得分率 ${summary.scoreRatio}%` : ''; return `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>发布时间 ${formatDate(items[0].publishedAt, true)}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong></span><span><small>合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span></div><div class="score-grid">${items.map(item => `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}</small></article>`).join('')}</div><footer><p>成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。</p><strong>已发布 ${items.length} 科</strong></footer></section>`; }).join('')}</div>`;
|
return `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => {
|
||||||
|
const summary = summaries.find(item => item.examId === items[0].examId);
|
||||||
|
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
|
||||||
|
const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `第 ${summary.rank} / ${summary.cohortSize} 名` : summary ? `得分率 ${summary.scoreRatio}%` : '';
|
||||||
|
const scores = items.map(item => {
|
||||||
|
const appeal = item.appeal;
|
||||||
|
const latestAction = appeal?.actions?.at(-1);
|
||||||
|
const appealPanel = appeal?.status === 'pending'
|
||||||
|
? `<div class="score-appeal-state">${badge('pending')}<small>${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}</small></div>`
|
||||||
|
: appeal?.status === 'approved'
|
||||||
|
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
|
||||||
|
: `${appeal ? `<div class="score-appeal-state">${badge('rejected')}<small>${h(latestAction?.note || '可补充理由后重新提交')}</small></div>` : ''}<form class="score-appeal-form" data-form="score-appeal"><input type="hidden" name="resultId" value="${h(item.id)}"><textarea name="reason" rows="2" minlength="5" maxlength="500" required placeholder="填写成绩复议理由(至少 5 个字)"></textarea><button class="row-action primary" type="submit">${appeal ? '重新申请复议' : '申请成绩复议'}</button></form>`;
|
||||||
|
return `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}</small>${appealPanel}</article>`;
|
||||||
|
}).join('');
|
||||||
|
return `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>发布时间 ${formatDate(items[0].publishedAt, true)}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong></span><span><small>合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span></div><div class="score-grid">${scores}</div><footer><p>如对成绩有异议,可直接提交复议,系统会按班级、学校和考试中心流程办理。</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateNotices(notices) {
|
function candidateNotices(notices) {
|
||||||
|
|||||||
+7
-1
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
return {
|
||||||
meta: { version: 7, createdAt: nowIso() },
|
meta: { version: 8, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: {
|
organization: {
|
||||||
name: '海州市教育考试中心',
|
name: '海州市教育考试中心',
|
||||||
@@ -29,6 +29,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
||||||
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
||||||
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
||||||
|
{ id: 'usr_class_admin_2', username: 'class_admin_2', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '李班管', active: true, createdAt: nowIso() },
|
||||||
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() }
|
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() }
|
||||||
],
|
],
|
||||||
candidateProfiles: [
|
candidateProfiles: [
|
||||||
@@ -119,6 +120,11 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
] },
|
] },
|
||||||
{ id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
{ id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
{ id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' }
|
{ id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' },
|
||||||
|
{ id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' }
|
||||||
] }
|
] }
|
||||||
],
|
],
|
||||||
workflowInstances: [],
|
workflowInstances: [],
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export const sqliteSchema = `
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
|
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch', 'score_appeal')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -315,7 +315,7 @@ export const sqliteSchema = `
|
|||||||
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE,
|
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE,
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')),
|
admin_level TEXT NOT NULL CHECK (admin_level IN ('class', 'school', 'super')),
|
||||||
UNIQUE (workflow_id, position)
|
UNIQUE (workflow_id, position)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
@@ -722,7 +722,7 @@ export const mysqlSchema = [
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS workflow_definitions (
|
`CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||||
id VARCHAR(64) NOT NULL,
|
id VARCHAR(64) NOT NULL,
|
||||||
business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL,
|
business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch', 'score_appeal') NOT NULL,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
updated_by VARCHAR(64) NULL,
|
updated_by VARCHAR(64) NULL,
|
||||||
@@ -736,7 +736,7 @@ export const mysqlSchema = [
|
|||||||
workflow_id VARCHAR(64) NOT NULL,
|
workflow_id VARCHAR(64) NOT NULL,
|
||||||
position INT UNSIGNED NOT NULL,
|
position INT UNSIGNED NOT NULL,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
admin_level ENUM('school', 'super') NOT NULL,
|
admin_level ENUM('class', 'school', 'super') NOT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_workflow_steps_position (workflow_id, position),
|
UNIQUE KEY uq_workflow_steps_position (workflow_id, position),
|
||||||
CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE
|
CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE
|
||||||
|
|||||||
+64
-13
@@ -16,6 +16,7 @@ export function createAdminRoutes(context) {
|
|||||||
registrationInScope,
|
registrationInScope,
|
||||||
adminScopeLabel,
|
adminScopeLabel,
|
||||||
adminsForStep,
|
adminsForStep,
|
||||||
|
selectAdminForStep,
|
||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
createWorkflowSubmission,
|
createWorkflowSubmission,
|
||||||
workflowView,
|
workflowView,
|
||||||
@@ -267,7 +268,7 @@ export function createAdminRoutes(context) {
|
|||||||
await database.processWorkflow(instance, action, batch, log);
|
await database.processWorkflow(instance, action, batch, log);
|
||||||
} else if (instance.currentStep < workflow.steps.length) {
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId))[0];
|
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId));
|
||||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
batch.reviewNote = note;
|
batch.reviewNote = note;
|
||||||
@@ -362,7 +363,7 @@ export function createAdminRoutes(context) {
|
|||||||
await database.applyCenterChange(change, instance, action, null, [], log);
|
await database.applyCenterChange(change, instance, action, null, [], log);
|
||||||
} else if (instance.currentStep < workflow.steps.length) {
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId))[0];
|
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId));
|
||||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
change.reviewNote = note;
|
change.reviewNote = note;
|
||||||
@@ -417,14 +418,15 @@ export function createAdminRoutes(context) {
|
|||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
return sendJson(response, 200, { ok: true, workflows: db.workflows });
|
return sendJson(response, 200, { ok: true, workflows: db.workflows });
|
||||||
}
|
}
|
||||||
const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch)$/);
|
const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch|score_appeal)$/);
|
||||||
if (workflowDefinitionMatch && request.method === 'PUT') {
|
if (workflowDefinitionMatch && request.method === 'PUT') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const workflow = activeWorkflow(db, workflowDefinitionMatch[1]);
|
const workflow = activeWorkflow(db, workflowDefinitionMatch[1]);
|
||||||
if (!workflow) return sendError(response, 404, '审批流程不存在');
|
if (!workflow) return sendError(response, 404, '审批流程不存在');
|
||||||
const steps = Array.isArray(body.steps) ? body.steps : [];
|
const steps = Array.isArray(body.steps) ? body.steps : [];
|
||||||
if (!steps.length || steps.some(item => !['school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个校级或超级管理员审批步骤');
|
if (!steps.length || steps.some(item => !['class', 'school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个班级、校级或超级管理员审批步骤');
|
||||||
|
if (['center_change', 'candidate_account_batch'].includes(workflowDefinitionMatch[1]) && steps.some(item => item.adminLevel === 'class')) return sendError(response, 400, '该业务不对应单一班级,不能配置班级管理员审批步骤');
|
||||||
if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批');
|
if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批');
|
||||||
workflow.name = cleanText(body.name, 80) || workflow.name;
|
workflow.name = cleanText(body.name, 80) || workflow.name;
|
||||||
workflow.updatedBy = user.id;
|
workflow.updatedBy = user.id;
|
||||||
@@ -436,7 +438,7 @@ export function createAdminRoutes(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') {
|
if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') {
|
||||||
if (user.adminLevel === 'class') return sendError(response, 403, '班级管理员只读查看考生、成绩和报名状态');
|
if (!requirePermission(user, response, 'workflows.inbox')) return true;
|
||||||
const instances = db.workflowInstances.filter(instance => {
|
const instances = db.workflowInstances.filter(instance => {
|
||||||
if (user.adminLevel === 'super') return true;
|
if (user.adminLevel === 'super') return true;
|
||||||
const profile = workflowScopeProfile(db, instance);
|
const profile = workflowScopeProfile(db, instance);
|
||||||
@@ -446,12 +448,17 @@ export function createAdminRoutes(context) {
|
|||||||
const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null;
|
const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null;
|
||||||
const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null;
|
const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null;
|
||||||
const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null;
|
const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null;
|
||||||
|
const appealResult = instance.businessType === 'score_appeal' ? db.results.find(item => item.id === instance.businessId) : null;
|
||||||
|
const appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null;
|
||||||
|
const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null;
|
||||||
|
const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId);
|
||||||
return {
|
return {
|
||||||
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
|
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
|
||||||
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
|
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
|
||||||
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
|
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
|
||||||
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
|
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
|
||||||
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0
|
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0,
|
||||||
|
appealResult: appealResult ? { score: appealResult.score, grade: appealResult.grade, examName: appealExam?.name || '', subjectName: appealSubject?.name || '' } : null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
|
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
|
||||||
@@ -469,6 +476,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员');
|
if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员');
|
||||||
const profile = workflowScopeProfile(db, instance);
|
const profile = workflowScopeProfile(db, instance);
|
||||||
if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员');
|
if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员');
|
||||||
|
if (step.adminLevel === 'class' && (target.schoolId !== profile?.schoolId || target.classId !== profile?.classId)) return sendError(response, 400, '班级流程只能转交给本班同级管理员');
|
||||||
const previous = instance.assigneeId;
|
const previous = instance.assigneeId;
|
||||||
instance.assigneeId = target.id;
|
instance.assigneeId = target.id;
|
||||||
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() };
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() };
|
||||||
@@ -488,7 +496,9 @@ export function createAdminRoutes(context) {
|
|||||||
const step = workflow.steps.find(item => item.position === requestedStep);
|
const step = workflow.steps.find(item => item.position === requestedStep);
|
||||||
const profile = workflowScopeProfile(db, instance);
|
const profile = workflowScopeProfile(db, instance);
|
||||||
const eligible = adminsForStep(db, step.adminLevel, profile);
|
const eligible = adminsForStep(db, step.adminLevel, profile);
|
||||||
const assignee = eligible.find(item => item.id === body.assigneeId) || eligible[0];
|
const requestedAssignee = body.assigneeId ? eligible.find(item => item.id === body.assigneeId) : null;
|
||||||
|
if (body.assigneeId && !requestedAssignee) return sendError(response, 400, '指定管理员不在该学校或班级的目标步骤范围内');
|
||||||
|
const assignee = requestedAssignee || selectAdminForStep(db, step.adminLevel, profile);
|
||||||
if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员');
|
if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员');
|
||||||
const previous = instance.assigneeId;
|
const previous = instance.assigneeId;
|
||||||
const previousStep = instance.currentStep;
|
const previousStep = instance.currentStep;
|
||||||
@@ -501,8 +511,12 @@ export function createAdminRoutes(context) {
|
|||||||
? db.registrations.find(item => item.id === instance.businessId)
|
? db.registrations.find(item => item.id === instance.businessId)
|
||||||
: instance.businessType === 'center_change'
|
: instance.businessType === 'center_change'
|
||||||
? db.centerChangeRequests.find(item => item.id === instance.businessId)
|
? db.centerChangeRequests.find(item => item.id === instance.businessId)
|
||||||
: db.candidateAccountBatches.find(item => item.id === instance.businessId);
|
: instance.businessType === 'candidate_account_batch'
|
||||||
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
|
? db.candidateAccountBatches.find(item => item.id === instance.businessId)
|
||||||
|
: null;
|
||||||
|
if (business) {
|
||||||
|
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
|
||||||
|
}
|
||||||
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
|
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
|
||||||
await database.processWorkflow(instance, action, business, log);
|
await database.processWorkflow(instance, action, business, log);
|
||||||
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
@@ -513,7 +527,6 @@ export function createAdminRoutes(context) {
|
|||||||
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||||
const visibleFlows = db.workflowInstances.filter(instance => {
|
const visibleFlows = db.workflowInstances.filter(instance => {
|
||||||
if (user.adminLevel === 'super') return true;
|
if (user.adminLevel === 'super') return true;
|
||||||
if (user.adminLevel === 'class') return false;
|
|
||||||
const business = workflowScopeProfile(db, instance);
|
const business = workflowScopeProfile(db, instance);
|
||||||
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
|
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
|
||||||
});
|
});
|
||||||
@@ -557,7 +570,7 @@ export function createAdminRoutes(context) {
|
|||||||
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
||||||
} else if (instance.currentStep < workflow.steps.length) {
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
|
||||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
profile.status = 'pending'; profile.reviewNote = note;
|
profile.status = 'pending'; profile.reviewNote = note;
|
||||||
@@ -598,7 +611,7 @@ export function createAdminRoutes(context) {
|
|||||||
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
||||||
} else if (instance.currentStep < workflow.steps.length) {
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
|
||||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
registration.status = 'pending'; registration.reviewNote = note;
|
registration.status = 'pending'; registration.reviewNote = note;
|
||||||
@@ -615,6 +628,39 @@ export function createAdminRoutes(context) {
|
|||||||
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
}
|
}
|
||||||
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||||
|
const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/);
|
||||||
|
if (request.method === 'PATCH' && scoreAppealMatch) {
|
||||||
|
if (!requirePermission(user, response, 'workflows.inbox')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '复议处理状态无效');
|
||||||
|
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
|
||||||
|
const registration = db.registrations.find(item => item.id === result?.registrationId);
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||||
|
if (!result || !registration || !profile) return sendError(response, 404, '待处理的成绩复议不存在');
|
||||||
|
if (!profileInScope(user, profile)) return sendError(response, 403, '该成绩复议不在你的数据范围内');
|
||||||
|
const instance = pendingWorkflow(db, 'score_appeal', result.id);
|
||||||
|
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||||
|
if (!instance || !workflow || !step) return sendError(response, 409, '成绩复议流程状态异常');
|
||||||
|
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||||
|
const note = cleanText(body.reviewNote, 300);
|
||||||
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||||
|
if (body.status === 'rejected') {
|
||||||
|
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
|
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
|
||||||
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
|
} else {
|
||||||
|
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
}
|
||||||
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
|
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||||
|
const log = logAction(db, user, body.status === 'approved' ? '处理成绩复议' : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}`);
|
||||||
|
await database.processWorkflow(instance, action, null, log);
|
||||||
|
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
|
}
|
||||||
if (request.method === 'POST' && admitMatch) {
|
if (request.method === 'POST' && admitMatch) {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
||||||
@@ -724,7 +770,12 @@ export function createAdminRoutes(context) {
|
|||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||||
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
|
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
|
||||||
});
|
});
|
||||||
return sendJson(response, 200, { ok: true, results, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
|
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
|
||||||
|
const result = results.find(item => item.id === instance.businessId);
|
||||||
|
const workflow = workflowView(db, instance);
|
||||||
|
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
||||||
|
});
|
||||||
|
return sendJson(response, 200, { ok: true, results, appeals, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
|||||||
@@ -126,11 +126,30 @@ export function createCandidateRoutes(context) {
|
|||||||
const registration = registrations.find(reg => reg.id === result.registrationId);
|
const registration = registrations.find(reg => reg.id === result.registrationId);
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
const subject = exam.subjects.find(item => item.id === result.subjectId);
|
const subject = exam.subjects.find(item => item.id === result.subjectId);
|
||||||
return { ...result, examId: exam.id, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passScore: subject?.passScore || 90 };
|
const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id);
|
||||||
|
const appeal = appealInstance ? workflowView(db, appealInstance) : null;
|
||||||
|
return { ...result, examId: exam.id, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passScore: subject?.passScore || 90, appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null };
|
||||||
});
|
});
|
||||||
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
||||||
return sendJson(response, 200, { ok: true, results, summaries });
|
return sendJson(response, 200, { ok: true, results, summaries });
|
||||||
}
|
}
|
||||||
|
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
|
||||||
|
if (request.method === 'POST' && scoreAppealMatch) {
|
||||||
|
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
|
||||||
|
const registration = db.registrations.find(item => item.id === result?.registrationId && item.userId === user.id);
|
||||||
|
if (!result || !registration) return sendError(response, 404, '已发布成绩不存在或不属于当前考生');
|
||||||
|
if (pendingWorkflow(db, 'score_appeal', result.id)) return sendError(response, 409, '该科成绩已有待处理复议,请勿重复提交');
|
||||||
|
const body = await readJson(request);
|
||||||
|
const reason = cleanText(body.reason, 500);
|
||||||
|
if (reason.length < 5) return sendError(response, 400, '请至少填写 5 个字的复议理由');
|
||||||
|
const { instance, action } = createWorkflowSubmission(db, 'score_appeal', result.id, profile, user.id);
|
||||||
|
action.note = reason;
|
||||||
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
|
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||||
|
const log = logAction(db, user, '提交成绩复议', `${exam?.name || ''} · ${subject?.name || ''}`);
|
||||||
|
await database.createWorkflow(instance, action, log);
|
||||||
|
return sendJson(response, 201, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
|
}
|
||||||
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
|
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
|
||||||
if (request.method === 'GET' && admitMatch) {
|
if (request.method === 'GET' && admitMatch) {
|
||||||
const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id);
|
const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const adminLevelNames = { super: '超级管理员', school: '校级管理
|
|||||||
export const permissionsByLevel = {
|
export const permissionsByLevel = {
|
||||||
super: ['*'],
|
super: ['*'],
|
||||||
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
|
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
|
||||||
class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read']
|
class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'workflows.inbox']
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hasPermission(user, permission) {
|
export function hasPermission(user, permission) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+47
-4
@@ -57,6 +57,7 @@ const admin = createClient();
|
|||||||
const schoolAdmin = createClient();
|
const schoolAdmin = createClient();
|
||||||
const schoolAdmin2 = createClient();
|
const schoolAdmin2 = createClient();
|
||||||
const classAdmin = createClient();
|
const classAdmin = createClient();
|
||||||
|
const classAdmin2 = createClient();
|
||||||
const candidate = createClient();
|
const candidate = createClient();
|
||||||
const selfCandidate = createClient();
|
const selfCandidate = createClient();
|
||||||
const batchCandidate = createClient();
|
const batchCandidate = createClient();
|
||||||
@@ -112,6 +113,7 @@ try {
|
|||||||
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school');
|
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||||
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
|
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||||
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
||||||
|
assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
||||||
|
|
||||||
assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号');
|
assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号');
|
||||||
const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
|
const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
|
||||||
@@ -359,9 +361,12 @@ try {
|
|||||||
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
||||||
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
||||||
assert.ok(registrationFlow, '报名应建立可追踪的审批实例');
|
assert.ok(registrationFlow, '报名应建立可追踪的审批实例');
|
||||||
const transfer = await schoolAdmin.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: 'usr_school_admin_2', note: '同级管理员协办' } });
|
const assignedSchoolClient = registrationFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin;
|
||||||
assert.equal(transfer.data.workflow.assignee.id, 'usr_school_admin_2', '同级管理员之间应可转交流程');
|
const transferTargetId = registrationFlow.assignee.id === 'usr_school_admin_2' ? 'usr_school_admin' : 'usr_school_admin_2';
|
||||||
const schoolApproveRegistration = await schoolAdmin2.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } });
|
const transferTargetClient = transferTargetId === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin;
|
||||||
|
const transfer = await assignedSchoolClient.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: transferTargetId, note: '同级管理员协办' } });
|
||||||
|
assert.equal(transfer.data.workflow.assignee.id, transferTargetId, '同级管理员之间应可转交流程');
|
||||||
|
const schoolApproveRegistration = await transferTargetClient.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } });
|
||||||
assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审');
|
assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审');
|
||||||
|
|
||||||
const allFlows = await admin.request('/api/admin/workflow-instances');
|
const allFlows = await admin.request('/api/admin/workflow-instances');
|
||||||
@@ -399,7 +404,9 @@ try {
|
|||||||
const futureExam = futureExamResponse.data.exam;
|
const futureExam = futureExamResponse.data.exam;
|
||||||
const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } });
|
const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } });
|
||||||
const futureRegistrationId = futureRegistration.data.registration.id;
|
const futureRegistrationId = futureRegistration.data.registration.id;
|
||||||
await schoolAdmin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } });
|
const futureFlow = (await schoolAdmin.request('/api/admin/workflow-instances')).data.instances.find(item => item.businessId === futureRegistrationId && item.status === 'pending');
|
||||||
|
const futureSchoolClient = futureFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin;
|
||||||
|
await futureSchoolClient.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } });
|
||||||
const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
||||||
assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号');
|
assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号');
|
||||||
await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
||||||
@@ -425,10 +432,45 @@ try {
|
|||||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||||
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
||||||
|
|
||||||
|
const appealResults = results.data.results.filter(item => [exam.subjects[0].id, exam.subjects[2].id].includes(item.subjectId));
|
||||||
|
assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议');
|
||||||
|
const submittedAppeals = [];
|
||||||
|
for (const result of appealResults) {
|
||||||
|
const submitted = await candidate.request(`/api/candidate/results/${result.id}/appeals`, { method: 'POST', body: { reason: `${result.subjectName}成绩与个人估分差异较大,请复核答卷计分。` } });
|
||||||
|
assert.equal(submitted.response.status, 201, '考生应能对已发布成绩提交复议');
|
||||||
|
submittedAppeals.push({ result, workflow: submitted.data.workflow });
|
||||||
|
}
|
||||||
|
assert.deepEqual(new Set(submittedAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_class_admin', 'usr_class_admin_2']), '同班多位班级管理员的新增复议应均分');
|
||||||
|
const classFlows = await classAdmin.request('/api/admin/workflow-instances');
|
||||||
|
assert.equal(classFlows.response.status, 200, '班级管理员应有流程中心和审批权限');
|
||||||
|
assert.ok(classFlows.data.instances.filter(item => item.businessType === 'score_appeal' && item.status === 'pending').every(item => item.assignee.classId === 'class_hz1_302' && item.assignee.schoolId === 'school_hz1'), '班级步骤只能分配给该校该班管理员');
|
||||||
|
const schoolAppeals = [];
|
||||||
|
for (const appeal of submittedAppeals) {
|
||||||
|
const classClient = appeal.workflow.assignee.id === 'usr_class_admin_2' ? classAdmin2 : classAdmin;
|
||||||
|
const classApproved = await classClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '班级已核验考生身份与科目' } });
|
||||||
|
assert.equal(classApproved.response.status, 200, '班级管理员应能审批分配给自己的成绩复议');
|
||||||
|
assert.equal(classApproved.data.workflow.currentStepDetail.adminLevel, 'school', '班级审批后应进入本校管理员步骤');
|
||||||
|
assert.equal(classApproved.data.workflow.assignee.schoolId, 'school_hz1', '校级步骤必须匹配考生所属学校');
|
||||||
|
schoolAppeals.push({ result: appeal.result, workflow: classApproved.data.workflow });
|
||||||
|
}
|
||||||
|
assert.deepEqual(new Set(schoolAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_school_admin', 'usr_school_admin_2']), '同校多位校级管理员的复议任务应均分');
|
||||||
|
for (const appeal of schoolAppeals) {
|
||||||
|
const schoolClient = appeal.workflow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin;
|
||||||
|
const schoolApproved = await schoolClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校完成成绩复核' } });
|
||||||
|
assert.equal(schoolApproved.response.status, 200);
|
||||||
|
assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审');
|
||||||
|
const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审完成' } });
|
||||||
|
assert.equal(finalApproved.data.workflow.status, 'approved', '成绩复议应可完整走完班级、学校和考试中心流程');
|
||||||
|
}
|
||||||
|
const appealedResults = await candidate.request('/api/candidate/results');
|
||||||
|
assert.ok(appealedResults.data.results.filter(item => appealResults.some(result => result.id === item.id)).every(item => item.appeal?.status === 'approved'), '考生端应显示每科复议结果');
|
||||||
|
|
||||||
const workflowDefinitions = await admin.request('/api/admin/workflows');
|
const workflowDefinitions = await admin.request('/api/admin/workflows');
|
||||||
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
||||||
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批');
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批');
|
||||||
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批');
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批');
|
||||||
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'score_appeal'), '流程设计应包含考生成绩复议');
|
||||||
|
assert.match(appSource, /renderAdmin, workflowStepEditor/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误');
|
||||||
const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } });
|
const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } });
|
||||||
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
|
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
|
||||||
|
|
||||||
@@ -442,6 +484,7 @@ try {
|
|||||||
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
||||||
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
|
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
|
||||||
console.log('✓ 成绩录入、发布与考生查询');
|
console.log('✓ 成绩录入、发布与考生查询');
|
||||||
|
console.log('✓ 成绩复议、班级审批、范围匹配与多人均分');
|
||||||
} finally {
|
} finally {
|
||||||
server.kill('SIGTERM');
|
server.kill('SIGTERM');
|
||||||
await new Promise(resolveWait => server.once('exit', resolveWait));
|
await new Promise(resolveWait => server.once('exit', resolveWait));
|
||||||
|
|||||||
Reference in New Issue
Block a user