流程设计

This commit is contained in:
2026-07-20 12:29:07 +08:00 Unverified
parent c5446d5241
commit 4c7fafc03d
13 changed files with 240 additions and 54 deletions
+12 -10
View File
@@ -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 `<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) {
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>`;
}
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>`;
}
@@ -94,7 +94,8 @@ export function createAdminViews(context) {
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>` : '';
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) {
@@ -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 `<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 = {}) {
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) {
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 => {
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 `<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>`;
}
return { renderAdmin };
return { renderAdmin, workflowStepEditor };
}
+18 -3
View File
@@ -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 `<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) {
@@ -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 `<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) {
+7 -1
View File
@@ -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: [],
+4 -4
View File
@@ -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
+64 -13
View File
@@ -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;
+20 -1
View File
@@ -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);
+1 -1
View File
@@ -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) {