diff --git a/README.md b/README.md index 9d7fc16..0e4da03 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - 查看开放考试并自主选择多个报考科目 - 查看报名、缴费和审核状态 - 准考证生成状态、开放时间与下载 -- 已发布成绩查询 +- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 - 通知公告中心 ### 管理后台 @@ -30,8 +30,9 @@ - 超级、校级、班级三级管理员,同一级支持多个账号 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 - 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 -- 班级管理员只读查看本班考生、成绩和报名状态 -- 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 +- 班级管理员可查看并审批本班考生、报名与成绩复议流程,成绩录入仍仅限超级管理员 +- 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 +- 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分 - 当前处理人可将流程转交给同范围的同级管理员 - 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 - 报名号在创建考生账户时只生成一次,后续考试报名自动复用 @@ -76,7 +77,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `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 src/data/china-regions.mjs`。 @@ -141,6 +142,7 @@ npm start | 校级管理员 | `school_admin` | `School123!` | | 同校校级管理员(转交演示) | `school_admin_2` | `School123!` | | 班级管理员 | `class_admin` | `Class123!` | +| 同班班级管理员(均分演示) | `class_admin_2` | `Class123!` | | 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` | ## 自动化测试 @@ -149,7 +151,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证和成绩完整流程。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证、成绩复议、校班严格匹配和多人均分。 ## 项目结构 diff --git a/app.js b/app.js index ac8b00e..3155911 100644 --- a/app.js +++ b/app.js @@ -38,7 +38,7 @@ function renderError(error) { const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); 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) { location.hash = route; @@ -307,6 +307,10 @@ document.addEventListener('submit', async event => { if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); await api('/api/candidate/registrations', { method: 'POST', body }); 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') { const body = formObject(form); await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body }); @@ -377,7 +381,9 @@ document.addEventListener('submit', async event => { ? `/api/admin/registrations/${body.businessId}` : body.businessType === 'center_change' ? `/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 } }); closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute(); } else if (kind === 'flow-transfer') { @@ -428,7 +434,7 @@ function openSchoolClassForm(schoolClass = null) { function openClassAdminForm(admin = null, classId = '') { const classes = state.pageData.classes || []; - setModal(``); + setModal(``); } function centerRoomEditor(room = {}) { @@ -445,14 +451,20 @@ function openCenterForm(center = null) { function openFlowDetail(id) { const instance = state.pageData.instances.find(item => item.id === id); const currentLevel = instance.currentStepDetail?.adminLevel; - const available = state.pageData.availableAdmins.filter(item => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId)); - const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || instance.assignee?.id === state.user.id); + const available = state.pageData.availableAdmins.filter(item => { + 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 => `
${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}
`).join(''); const isCenter = instance.businessType === 'center_change'; const isBatch = instance.businessType === 'candidate_account_batch'; + const isAppeal = instance.businessType === 'score_appeal'; const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length; - const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName; - const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; + const 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 ? '按班级批量申领 · 批准后生成账号' : 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 changeSnapshot = change ? `
申请快照

${h(change.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${h(change.address)}
负责人
${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
开放时间
${h(change.gateOpenTime || '未填写')}
档案状态
${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}`).join('')}
` : ''; const batch = instance.accountBatch; @@ -463,13 +475,13 @@ function openFlowDetail(id) { function openCandidateReview(id) { const source = state.pageData.candidates.find(candidate => candidate.id === id); 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(`
证件号码
${h(item.idNumberMasked)}
性别 / 籍贯
${h(item.gender)} · ${h(item.nativePlace)}
联系电话
${h(item.phone)}
电子邮箱
${h(item.email)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
家庭住址
${h(item.address)}
监护人
${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}
当前步骤
${h(item.workflow?.currentStepDetail?.name || '流程已结束')}
当前责任人
${h(item.workflow?.assignee?.displayName || '—')}
${canReview ? `` : ''}`); } function openRegistrationReview(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(`
报考科目

${reg.subjects.map(subject => `${h(subject.name)}`).join('')}

账户报名号
${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}
当前步骤
${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
${canReview ? `` : ''}`); } diff --git a/database.mjs b/database.mjs index 74f8e54..c6d4cf8 100644 --- a/database.mjs +++ b/database.mjs @@ -53,7 +53,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; 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, state.meta?.createdAt || new Date().toISOString() ); @@ -974,6 +974,11 @@ function createRepository({ client, location, read, transaction, close }) { if (log) operations.push(auditOperation(log)); 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) { const operations = [ operation( diff --git a/server.mjs b/server.mjs index 3e02763..729c5a7 100644 --- a/server.mjs +++ b/server.mjs @@ -72,10 +72,33 @@ function adminsForStep(db, adminLevel, profile) { if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false; if (adminLevel === 'super') return true; 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) { 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); if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 }); 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 }); const instance = { 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); 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; } @@ -536,6 +564,7 @@ const routeContext = { registrationInScope, adminScopeLabel, adminsForStep, + selectAdminForStep, activeWorkflow, createWorkflowSubmission, workflowView, diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 5088e92..fd69656 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -55,7 +55,7 @@ export function createAdminViews(context) { function adminDashboard(data) { const m = data.metrics; - const canFlow = state.user.adminLevel !== 'class'; + const canFlow = true; return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; } @@ -70,12 +70,12 @@ export function createAdminViews(context) { } function adminCandidates(candidates) { - const readOnly = state.user.adminLevel === 'class'; + const readOnly = false; return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; } function adminRegistrations(registrations) { - const readOnly = state.user.adminLevel === 'class'; + const readOnly = false; return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目账户报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; } @@ -94,7 +94,8 @@ export function createAdminViews(context) { function adminResults(data) { const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

按科目满分校验

选择科目后显示满分与单科合格线。

` : ''; - return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; + const appealLedger = `

成绩复议台账

复议按考生所属班级和学校自动分配,可在流程中心办理。

${(data.appeals || []).map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
暂无成绩复议申请
`; + return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
${appealLedger}`; } function adminUsers(data) { @@ -135,25 +136,26 @@ export function createAdminViews(context) { } 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 `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; } function workflowStepEditor(step = {}) { - return `
`; + return `
`; } function adminFlows(data) { 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 `
${data.instances.map(instance => { const isCenter = instance.businessType === 'center_change'; const isBatch = instance.businessType === 'candidate_account_batch'; - const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName; - 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 isAppeal = instance.businessType === 'score_appeal'; + 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 `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}
`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`; } - return { renderAdmin }; + return { renderAdmin, workflowStepEditor }; } diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 407e793..9a3709a 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -25,7 +25,7 @@ export function createCandidateViews(context) { function adminNavForUser() { const level = state.user?.adminLevel || 'super'; 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']]; 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]]; @@ -35,7 +35,7 @@ export function createCandidateViews(context) { const nav = role === 'admin' ? adminNavForUser() : candidateNav; const roleName = role === 'admin' ? '管理后台' : '考生中心'; const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; - return `
${roleName}/${h(title)}
${role === 'admin' && state.user?.adminLevel !== 'class' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; + return `
${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; } function portalHeadingAction(role, page) { @@ -138,7 +138,22 @@ export function createCandidateViews(context) { const { results, summaries = [] } = data; 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), {}); - return `
${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 `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}合格判定${h(stateText)}${h(detail)}
${items.map(item => `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}
`).join('')}

成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。

已发布 ${items.length} 科
`; }).join('')}
`; + return `
${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' + ? `
${badge('pending')}${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}
` + : appeal?.status === 'approved' + ? `
${badge('approved')}${h(latestAction?.note || '复议流程已完成')}
` + : `${appeal ? `
${badge('rejected')}${h(latestAction?.note || '可补充理由后重新提交')}
` : ''}
`; + return `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}${appealPanel}
`; + }).join(''); + return `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}合格判定${h(stateText)}${h(detail)}
${scores}
`; + }).join('')}
`; } function candidateNotices(notices) { diff --git a/src/data/seed.mjs b/src/data/seed.mjs index 44e9523..7241d35 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 7, createdAt: nowIso() }, + meta: { version: 8, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { 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: 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_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() } ], 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_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: [], diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 8c96b00..f27b536 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -302,7 +302,7 @@ export const sqliteSchema = ` CREATE TABLE IF NOT EXISTS workflow_definitions ( 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, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), 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, position INTEGER 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) ) STRICT; @@ -722,7 +722,7 @@ export const mysqlSchema = [ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_definitions ( 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, active BOOLEAN NOT NULL DEFAULT TRUE, updated_by VARCHAR(64) NULL, @@ -736,7 +736,7 @@ export const mysqlSchema = [ workflow_id VARCHAR(64) NOT NULL, position INT UNSIGNED 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), 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 diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 9710dac..17c0971 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -16,6 +16,7 @@ export function createAdminRoutes(context) { registrationInScope, adminScopeLabel, adminsForStep, + selectAdminForStep, activeWorkflow, createWorkflowSubmission, workflowView, @@ -267,7 +268,7 @@ export function createAdminRoutes(context) { await database.processWorkflow(instance, action, batch, log); } else if (instance.currentStep < workflow.steps.length) { 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}”的管理员`); instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; batch.reviewNote = note; @@ -362,7 +363,7 @@ export function createAdminRoutes(context) { await database.applyCenterChange(change, instance, action, null, [], log); } else if (instance.currentStep < workflow.steps.length) { 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}”的管理员`); instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; change.reviewNote = note; @@ -417,14 +418,15 @@ export function createAdminRoutes(context) { if (!requirePermission(user, response, '*')) return true; 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 (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const workflow = activeWorkflow(db, workflowDefinitionMatch[1]); if (!workflow) return sendError(response, 404, '审批流程不存在'); 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, '批量报名号申领的最终步骤必须由超级管理员审批'); workflow.name = cleanText(body.name, 80) || workflow.name; workflow.updatedBy = user.id; @@ -436,7 +438,7 @@ export function createAdminRoutes(context) { } 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 => { if (user.adminLevel === 'super') return true; 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 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 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 { ...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '', examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '', centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : 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); @@ -469,6 +476,7 @@ export function createAdminRoutes(context) { if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员'); const profile = workflowScopeProfile(db, instance); 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; 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() }; @@ -488,7 +496,9 @@ export function createAdminRoutes(context) { const step = workflow.steps.find(item => item.position === requestedStep); const profile = workflowScopeProfile(db, instance); 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, '目标步骤没有可用管理员'); const previous = instance.assigneeId; const previousStep = instance.currentStep; @@ -501,8 +511,12 @@ export function createAdminRoutes(context) { ? db.registrations.find(item => item.id === instance.businessId) : instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) - : db.candidateAccountBatches.find(item => item.id === instance.businessId); - business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null; + : instance.businessType === 'candidate_account_batch' + ? 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}`); await database.processWorkflow(instance, action, business, log); 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 visibleFlows = db.workflowInstances.filter(instance => { if (user.adminLevel === 'super') return true; - if (user.adminLevel === 'class') return false; const business = workflowScopeProfile(db, instance); 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; } else if (instance.currentStep < workflow.steps.length) { 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}”的管理员`); instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; profile.status = 'pending'; profile.reviewNote = note; @@ -598,7 +611,7 @@ export function createAdminRoutes(context) { registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso(); } else if (instance.currentStep < workflow.steps.length) { 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}”的管理员`); instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; 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) }); } 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 (!requirePermission(user, response, '*')) return true; 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); 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 (!requirePermission(user, response, '*')) return true; diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 6de71f9..f5d863c 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -126,11 +126,30 @@ export function createCandidateRoutes(context) { const registration = registrations.find(reg => reg.id === result.registrationId); const exam = db.exams.find(item => item.id === registration.examId); 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); 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$/); if (request.method === 'GET' && admitMatch) { const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id); diff --git a/src/security/authorization.mjs b/src/security/authorization.mjs index 2eaee90..b6c52e9 100644 --- a/src/security/authorization.mjs +++ b/src/security/authorization.mjs @@ -3,7 +3,7 @@ export const adminLevelNames = { super: '超级管理员', school: '校级管理 export const permissionsByLevel = { super: ['*'], 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) { diff --git a/styles.css b/styles.css index 4dd5340..582acae 100644 --- a/styles.css +++ b/styles.css @@ -249,6 +249,8 @@ button:disabled { cursor: not-allowed; opacity: .5; } .registration-cards { display:grid; gap:15px; }.registration-card { border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.registration-card > header { display:flex; align-items:center; justify-content:space-between; padding:18px 22px; border-bottom:1px solid var(--line); }.registration-card > header h2 { margin:3px 0 0; font-family:"STKaiti"; font-size:19px; font-weight:400; }.registration-info { display:grid; grid-template-columns:.8fr 1.2fr; }.registration-info > dl { display:grid; gap:13px; margin:0; padding:22px; border-right:1px solid var(--line); background:#fafbfc; }.registration-info dl div { display:grid; gap:3px; }.registration-info dt { color:#969dac; font-size:8px; }.registration-info dd { margin:0; color:#5c667b; font-family:Consolas,monospace; font-size:9px; }.selected-subjects { padding:22px; }.selected-subjects > strong { font-size:10px; }.selected-subjects > div { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.selected-subjects span { min-width:105px; display:grid; gap:3px; padding:9px; border:1px solid var(--line); border-radius:7px; font-size:9px; }.selected-subjects small { color:#9299aa; font-size:7px; }.registration-card > footer { display:flex; justify-content:space-between; align-items:center; padding:13px 22px; border-top:1px solid var(--line); }.registration-card > footer p { margin:0; color:#818a9c; font-size:8px; }.admit-list { display:grid; gap:18px; }.admit-ticket { position:relative; display:grid; grid-template-columns:1fr 205px; border-radius:var(--radius); color:#fff; background:var(--navy); box-shadow:var(--shadow); overflow:hidden; }.admit-ticket::before,.admit-ticket::after { content:""; position:absolute; right:193px; width:24px; height:24px; border-radius:50%; background:var(--paper); }.admit-ticket::before { top:-12px; }.admit-ticket::after { bottom:-12px; }.admit-main { padding:27px 30px; }.admit-main header { display:flex; justify-content:space-between; }.admit-main header > span { color:#8291ba; font-family:Consolas,monospace; font-size:9px; }.admit-main h2 { margin:16px 0 19px; font-family:"STKaiti"; font-size:23px; font-weight:400; }.admit-number { display:flex; align-items:flex-end; gap:14px; margin-bottom:18px; }.admit-number small { color:#8290b7; font-size:8px; }.admit-number strong { font-family:Consolas,monospace; font-size:18px; letter-spacing:1px; }.admit-main dl { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin:0; }.admit-main dl div { display:grid; gap:4px; }.admit-main dt { color:#7988b2; font-size:8px; }.admit-main dd { margin:0; color:#d6ddef; font-size:9px; }.admit-stub { display:flex; align-items:center; flex-direction:column; justify-content:center; gap:15px; padding:24px; border-left:1px dashed rgba(255,255,255,.18); text-align:center; }.admit-stub > span { color:#6f7fac; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.admit-stub i { width:36px; height:1px; background:rgba(255,255,255,.15); }.admit-stub .solid-button { border-color:#fff; color:var(--navy); background:#fff; box-shadow:none; }.admit-stub small { color:#7e8db6; font-size:7px; }.result-groups { display:grid; gap:16px; }.result-panel { overflow:hidden; }.result-panel > header { display:flex; justify-content:space-between; align-items:center; padding:20px 23px; border-bottom:1px solid var(--line); }.result-panel > header span { color:#7f899f; font-family:Consolas,monospace; font-size:8px; }.result-panel > header h2 { margin:4px 0 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.result-panel > header small { color:#8c94a4; font-size:8px; }.score-grid { display:grid; grid-template-columns:repeat(5,1fr); }.score-grid article { position:relative; min-height:145px; display:grid; place-content:center; justify-items:center; padding:18px; border-right:1px solid var(--line); }.score-grid article:last-child { border-right:0; }.score-grid span { color:#596379; font-size:10px; }.score-grid strong { margin:8px 0 1px; font-family:Georgia,serif; font-size:37px; font-weight:400; }.score-grid em { position:absolute; top:15px; right:15px; padding:3px 6px; border-radius:4px; color:#27795c; background:#e2f3ec; font-size:8px; font-style:normal; }.score-grid small { color:#a0a6b3; font-size:7px; }.result-panel > footer { display:flex; justify-content:space-between; padding:13px 22px; border-top:1px solid var(--line); color:#878f9f; font-size:8px; }.result-panel > footer p { margin:0; }.notice-center { overflow:hidden; }.notice-center-list > button { width:100%; min-height:83px; display:grid; grid-template-columns:55px 1fr auto 20px; align-items:center; gap:15px; padding:13px 20px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.notice-center-list > button:last-child { border-bottom:0; }.notice-center-list > button:hover { background:#fafbfc; }.notice-center time { display:grid; justify-items:center; color:var(--navy); }.notice-center time strong { font-family:Georgia,serif; font-size:22px; font-weight:400; }.notice-center time span { color:#8e96a7; font-size:8px; }.notice-center button > span { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:4px 8px; }.notice-center em { padding:3px 5px; border-radius:4px; color:var(--red); background:#fbe9e7; font-size:7px; font-style:normal; }.notice-center button > span strong { font-size:10px; }.notice-center button > span small { grid-column:1/-1; overflow:hidden; color:#8b93a4; font-size:8px; text-overflow:ellipsis; white-space:nowrap; }.notice-center button > i { color:var(--red); font-size:7px; font-style:normal; } .empty-panel { min-height:370px; display:grid; place-content:center; justify-items:center; padding:40px; text-align:center; }.empty-panel > span { width:56px; height:56px; display:grid; place-items:center; border-radius:50%; color:#687da9; background:#edf1f8; }.empty-panel > span svg { width:24px; }.empty-panel h2 { margin:17px 0 7px; font-family:"STKaiti"; font-size:22px; font-weight:400; }.empty-panel p { max-width:440px; margin:0 0 18px; color:#858d9e; font-size:9px; line-height:1.8; } +.score-appeal-state { width:100%; display:grid; justify-items:center; gap:5px; margin-top:10px; padding-top:9px; border-top:1px solid var(--line); }.score-appeal-state small { max-width:180px; text-align:center; line-height:1.5; }.score-appeal-form { width:100%; display:grid; gap:7px; margin-top:10px; padding-top:9px; border-top:1px solid var(--line); }.score-appeal-form textarea { width:100%; min-height:52px; padding:7px 8px; resize:vertical; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:8px; }.score-appeal-form .row-action { justify-self:stretch; } + /* Admin */ .admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; } .data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 1213b5e..69f995c 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -57,6 +57,7 @@ const admin = createClient(); const schoolAdmin = createClient(); const schoolAdmin2 = createClient(); const classAdmin = createClient(); +const classAdmin2 = createClient(); const candidate = createClient(); const selfCandidate = 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 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 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, '超级管理员不得再从旧入口直接生成报名号'); 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 registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending'); assert.ok(registrationFlow, '报名应建立可追踪的审批实例'); - const transfer = await schoolAdmin.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: 'usr_school_admin_2', note: '同级管理员协办' } }); - assert.equal(transfer.data.workflow.assignee.id, 'usr_school_admin_2', '同级管理员之间应可转交流程'); - const schoolApproveRegistration = await schoolAdmin2.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } }); + const assignedSchoolClient = registrationFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; + const transferTargetId = registrationFlow.assignee.id === 'usr_school_admin_2' ? 'usr_school_admin' : 'usr_school_admin_2'; + 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', '校级初审后报名仍应待终审'); const allFlows = await admin.request('/api/admin/workflow-instances'); @@ -399,7 +404,9 @@ try { 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 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: '通过' } }); assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号'); 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.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 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 === '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 })) } }); assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); @@ -442,6 +484,7 @@ try { console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); console.log('✓ 成绩录入、发布与考生查询'); + console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); } finally { server.kill('SIGTERM'); await new Promise(resolveWait => server.once('exit', resolveWait));