diff --git a/README.md b/README.md index ea382ec..a2d5577 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ - 超级、校级、班级三级管理员,同一级支持多个账号 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 - 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 -- 班级管理员可查看并审批本班考生、报名与成绩复议流程,并在线下收款后确认考生已缴费;成绩录入仍仅限超级管理员 -- 报名终审与缴费确认相互独立,系统不接入支付 SDK;仅考生所属班级负责人可确认缴费,并记录确认人和时间 -- 超级、校级、班级管理员均可按各自数据范围查看及导出 Excel 缴费名单 +- 班级管理员可查看并审批本班考生、报名与成绩复议流程,并维护本班考生缴费状态;成绩录入仍仅限超级管理员 +- 报名终审与缴费确认相互独立,系统不接入支付 SDK;超级、校级、班级管理员均可在各自数据范围内修改缴费状态,确认缴费时记录办理人和时间 +- 超级、校级、班级管理员均可按各自数据范围筛选、查看及导出 Excel 缴费名单 - 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 - 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分 - 当前处理人可将流程转交给同范围的同级管理员 @@ -230,7 +230,7 @@ MySQL 模式会自动识别由本项目生成的批量样例数据并清理。 npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、班级负责人缴费确认、三级缴费名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、三级管理员范围内缴费状态修改与名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 ## 项目结构 diff --git a/app.js b/app.js index 79c9dab..edc0d9b 100644 --- a/app.js +++ b/app.js @@ -81,6 +81,38 @@ function formObject(form) { return Object.fromEntries(new FormData(form).entries()); } +function updateRegistrationSelection() { + const table = document.querySelector('#registrationTable'); + if (!table) return; + const selectable = [...table.querySelectorAll('[data-registration-select]:not(:disabled)')]; + const visible = selectable.filter(input => !input.closest('tr').hidden); + const selected = selectable.filter(input => input.checked); + const selectAll = table.querySelector('[data-registration-select-all]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = document.querySelector('[data-registration-selection-count]'); + if (count) count.textContent = selected.length; + document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; }); +} + +function applyTableFilters(tableId) { + const table = document.getElementById(tableId); + if (!table) return; + const search = [...document.querySelectorAll('[data-action="table-search"]')].find(input => input.dataset.target === tableId); + const query = search?.value.trim().toLowerCase() || ''; + const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all'; + const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value); + table.querySelectorAll('tbody tr[data-filter-row], tbody tr[data-status]').forEach(row => { + const matchesSearch = !query || row.textContent.toLowerCase().includes(query); + const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status); + const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '') === select.value); + row.hidden = !(matchesSearch && matchesStatus && matchesFilters); + }); + if (tableId === 'registrationTable') updateRegistrationSelection(); +} + async function refreshPublic() { state.publicData = await api('/api/public/home'); } @@ -227,12 +259,43 @@ document.addEventListener('click', async event => { toast(archived ? '账户已归档' : '账户已恢复', `${data.scopeLabel} · ${data.count} 个账户状态已更新`); return renderRoute(); } if (action === 'review-registration') return openRegistrationReview(target.dataset.id); + if (action === 'bulk-registration-review') { + const ids = [...document.querySelectorAll('#registrationTable [data-registration-select]:checked')].map(input => input.value); + if (!ids.length) return toast('请先选择报名记录', '仅待审核记录可批量处理'); + const status = target.dataset.status; + const promptText = status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)'; + const reviewNote = window.prompt(promptText, ''); + if (reviewNote === null) return; + if (status === 'rejected' && !reviewNote.trim()) return toast('请填写退回原因', '考生需要根据原因修改报名'); + let completed = 0; + const failures = []; + for (const id of ids) { + try { + await api(`/api/admin/registrations/${id}`, { method: 'PATCH', body: { status, reviewNote } }); + completed += 1; + } catch (error) { + failures.push(error.message); + } + } + toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新'); + return renderRoute(); + } if (action === 'confirm-payment') { if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return; await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' }); toast('缴费已确认', '考生端状态、确认人和确认时间已同步记录'); return renderRoute(); } + if (action === 'update-payment') { + const paid = target.dataset.status === 'paid'; + const message = paid + ? `确认将 ${target.dataset.name} 的“${target.dataset.exam}”标记为已缴费吗?` + : `确认撤销 ${target.dataset.name} 的“${target.dataset.exam}”缴费记录,并改为待缴费吗?`; + if (!window.confirm(message)) return; + await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); + toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除'); + return renderRoute(); + } if (action === 'excel-download') { const query = new URLSearchParams(); if (target.dataset.template === '1') query.set('template', '1'); @@ -297,15 +360,14 @@ document.addEventListener('click', async event => { } if (action === 'status-filter') { target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target)); - document.querySelectorAll(`#${target.dataset.target} tbody tr`).forEach(row => row.hidden = target.dataset.status !== 'all' && !String(row.dataset.status || '').split(/\s+/).includes(target.dataset.status)); + applyTableFilters(target.dataset.target); } } catch (error) { toast('操作未完成', error.message); } }); document.addEventListener('input', event => { if (event.target.matches('[data-action="table-search"]')) { - const query = event.target.value.trim().toLowerCase(); - document.querySelectorAll(`#${event.target.dataset.target} tbody tr`).forEach(row => row.hidden = !row.textContent.toLowerCase().includes(query)); + applyTableFilters(event.target.dataset.target); } if (event.target.matches('.subject-options input')) { const form = event.target.closest('form'); @@ -320,6 +382,15 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target); + if (event.target.matches('[data-registration-select]')) updateRegistrationSelection(); + if (event.target.matches('[data-registration-select-all]')) { + const table = document.getElementById(event.target.dataset.target); + table?.querySelectorAll('[data-registration-select]:not(:disabled)').forEach(input => { + if (!input.closest('tr').hidden) input.checked = event.target.checked; + }); + updateRegistrationSelection(); + } if (event.target.matches('[data-excel-file]')) { const input = event.target; const file = input.files?.[0]; @@ -674,7 +745,7 @@ function openCandidatePasswordReset(id) { function openRegistrationReview(id) { const reg = state.pageData.registrations.find(item => item.id === id); - const canReview = reg.status === 'pending' && reg.workflow?.assignee?.id === state.user.id; + const canReview = reg.status === 'pending' && (reg.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super'); 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/src/client/admin-views.mjs b/src/client/admin-views.mjs index c01a0d5..2939d4c 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -34,7 +34,7 @@ export function createAdminViews(context) { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], - payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', state.user.adminLevel === 'class' ? '考生线下缴费后,由班级负责人确认并记录办理时间。' : '查看并导出当前管理范围内的考试缴费名单。'], + payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', '查看、导出并修改当前管理范围内考生的考试缴费状态。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], @@ -66,7 +66,7 @@ export function createAdminViews(context) { function adminDashboard(data) { const m = data.metrics; const canFlow = true; - return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.ticket}
待确认缴费${m.pendingPayments ?? 0}${state.user.adminLevel === 'class' ? '由本班负责人办理' : '按管理范围统计'}
${icons.exam}
待处理流程${m.pendingFlows ?? 0}进入流程中心办理
${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('') || '

当前账号暂无操作记录

'}
`; + return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.ticket}
待确认缴费${m.pendingPayments ?? 0}可按当前管理范围办理
${icons.exam}
待处理流程${m.pendingFlows ?? 0}进入流程中心办理
${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('') || '

当前账号暂无操作记录

'}
`; } function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { @@ -97,12 +97,19 @@ export function createAdminViews(context) { } function adminRegistrations(registrations) { - const readOnly = false; - const rows = items => items.map(reg => `
${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.exam.archivedAt ? 'archived' : reg.status)}${reg.exam.archivedAt ? '只读封存' : ``}`).join(''); - const table = (items, id) => `
${rows(items)}
考生考试 / 科目账户报名号当前流程缴费状态操作
`; + const isSuper = state.user.adminLevel === 'super'; + const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const rows = (items, selectable = false) => items.map(reg => { + const canSelect = selectable && reg.status === 'pending' && reg.workflow?.status === 'pending'; + return `${selectable ? `` : ''}
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h([reg.schoolName, reg.gradeName, reg.className].filter(Boolean).join(' · '))}
${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.exam.archivedAt ? 'archived' : reg.status)}${reg.exam.archivedAt ? '只读封存' : ``}`; + }).join(''); + const table = (items, id, selectable = false) => `
${selectable ? '' : ''}${rows(items, selectable) || ``}
考生考试 / 科目账户报名号当前流程缴费状态操作
当前范围暂无报名记录
`; const current = registrations.filter(reg => !reg.exam.archivedAt); const archived = registrations.filter(reg => reg.exam.archivedAt); - return `
${table(current, 'registrationTable')}
${archived.length ? `
归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}${table(archived, 'archivedRegistrationTable')}
` : ''}`; + const bulkToolbar = isSuper ? `
已选择 0 条待审核报名
` : ''; + return `
${bulkToolbar}
${table(current, 'registrationTable', isSuper)}
${archived.length ? `
归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}${table(archived, 'archivedRegistrationTable')}
` : ''}`; } function adminPayments(data) { @@ -111,8 +118,12 @@ export function createAdminViews(context) { const unpaid = registrations.filter(item => item.paymentStatus === 'unpaid'); const totalDue = registrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); const totalPaid = paid.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); - const rows = registrations.map(item => `
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h(item.className)}${h(item.exam?.name || '')}${item.subjects.map(subject => h(subject.name)).join('、')}${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '班级负责人')}` : ''}${data.canConfirmPayment && item.paymentStatus === 'unpaid' && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); - return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${registrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${rows || ''}
考生 / 报名号学校 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
`; + const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const rows = registrations.map(item => `
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h([item.gradeName, item.className].filter(Boolean).join(' · '))}${h(item.exam?.name || '')}${item.subjects.map(subject => h(subject.name)).join('、')}${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '管理员')}` : ''}${data.canUpdatePayment && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); + const filters = `
`; + return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${registrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${filters}
${rows || ''}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
`; } function adminExams(exams) { diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 692c725..3ca37f6 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -722,7 +722,14 @@ export function createAdminRoutes(context) { if (!requirePermission(user, response, 'registrations.read')) return true; const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => { const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null }; + const schoolClass = db.classes.find(item => item.id === profile?.classId); + return { + ...examRegistrationView(db, registration), + candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null, + schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '', + gradeName: schoolClass?.grade || '', + className: schoolClass?.name || profile?.grade || '' + }; }); return sendJson(response, 200, { ok: true, registrations }); } @@ -737,6 +744,7 @@ export function createAdminRoutes(context) { ...view, candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null, schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '', + gradeName: db.classes.find(item => item.id === profile?.classId)?.grade || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '', amountDue: Number(view.subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)), paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '' @@ -745,30 +753,34 @@ export function createAdminRoutes(context) { return sendJson(response, 200, { ok: true, scopeLabel: adminScopeLabel(db, user), - canConfirmPayment: user.adminLevel === 'class', + canConfirmPayment: hasPermission(user, 'payments.write'), + canUpdatePayment: hasPermission(user, 'payments.write'), registrations }); } const paymentMatch = pathname.match(/^\/api\/admin\/payments\/([^/]+)$/); if (request.method === 'PATCH' && paymentMatch) { - if (user.adminLevel !== 'class') return sendError(response, 403, '缴费确认只能由考生所属班级负责人办理'); + if (!requirePermission(user, response, 'payments.write')) return true; + const body = await readJson(request); const registration = db.registrations.find(item => item.id === paymentMatch[1]); - if (!registration || !registrationInScope(db, user, registration)) return sendError(response, 404, '缴费记录不存在或不在本班范围内'); + if (!registration || !registrationInScope(db, user, registration)) return sendError(response, 404, '缴费记录不存在或不在当前管理范围内'); if (registration.status !== 'approved') return sendError(response, 409, '报名审核通过后才能确认缴费'); if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,缴费记录已冻结'); - if (registration.paymentStatus === 'paid') return sendError(response, 409, '该考生已经确认缴费,请勿重复操作'); - registration.paymentStatus = 'paid'; - registration.paidAt = nowIso(); - registration.paidBy = user.id; + const nextStatus = body.status || 'paid'; + if (!['paid', 'unpaid'].includes(nextStatus)) return sendError(response, 400, '缴费状态无效'); + if (registration.paymentStatus === nextStatus) return sendError(response, 409, `该考生已经是${nextStatus === 'paid' ? '已缴费' : '待缴费'}状态`); + registration.paymentStatus = nextStatus; + registration.paidAt = nextStatus === 'paid' ? nowIso() : null; + registration.paidBy = nextStatus === 'paid' ? user.id : null; const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const exam = db.exams.find(item => item.id === registration.examId); await database.updateRegistrationPayment( registration, - logAction(db, user, '确认考生缴费', `${profile?.name || registration.registrationNumber} · ${exam?.name || registration.examId}`) + logAction(db, user, nextStatus === 'paid' ? '标记考生已缴费' : '撤销考生缴费确认', `${profile?.name || registration.registrationNumber} · ${exam?.name || registration.examId}`) ); return sendJson(response, 200, { ok: true, - payment: { registrationId: registration.id, status: registration.paymentStatus, paidAt: registration.paidAt, paidBy: registration.paidBy, paidByName: user.displayName } + payment: { registrationId: registration.id, status: registration.paymentStatus, paidAt: registration.paidAt, paidBy: registration.paidBy, paidByName: nextStatus === 'paid' ? user.displayName : '' } }); } if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') { diff --git a/src/security/authorization.mjs b/src/security/authorization.mjs index b4eb8f0..223d4eb 100644 --- a/src/security/authorization.mjs +++ b/src/security/authorization.mjs @@ -2,7 +2,7 @@ export const adminLevelNames = { super: '超级管理员', school: '校级管理 export const permissionsByLevel = { super: ['*'], - school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], + school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'workflows.inbox'] }; diff --git a/styles.css b/styles.css index 0883060..8976ed8 100644 --- a/styles.css +++ b/styles.css @@ -254,6 +254,7 @@ button:disabled { cursor: not-allowed; opacity: .5; } /* 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; } +.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; } .admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; } .exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); } .exam-score-band > span { min-height:64px; display:flex; align-items:baseline; gap:5px; padding:13px 22px; } @@ -335,6 +336,11 @@ button:disabled { cursor: not-allowed; opacity: .5; } .region-selects { grid-template-columns:1fr; } .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } } +@media (max-width: 720px) { + .table-filter-selects { grid-template-columns:1fr 1fr; } + .registration-bulk-bar { align-items:stretch; flex-direction:column; } + .registration-bulk-bar > div { display:grid; grid-template-columns:1fr 1fr; } +} .flow-appeal-snapshot { margin:18px 24px 4px; padding:16px; border:1px solid #d4dfed; border-radius:10px; background:linear-gradient(120deg,#f7f9fd,#eef4fb); } .flow-appeal-snapshot header { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 7005e00..358772b 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -617,6 +617,7 @@ try { const adminRegistrations = await admin.request('/api/admin/registrations'); const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId); assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列'); + assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段'); const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances'); const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending'); @@ -647,10 +648,16 @@ try { assert.ok(schoolPaymentList.data.registrations.some(item => item.id === registrationId), '校级管理员应能查看本校缴费名单'); assert.ok(classPaymentList.data.registrations.some(item => item.id === registrationId), '班级负责人应能查看本班缴费名单'); assert.equal(classPaymentList.data.canConfirmPayment, true, '班级负责人应取得缴费确认能力'); - assert.equal(superPaymentList.data.canConfirmPayment, false, '超级管理员只能查看和导出,不得代班确认'); - assert.equal((await admin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 403, '超级管理员不得确认考生缴费'); - assert.equal((await schoolAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 403, '校级管理员不得确认考生缴费'); - const confirmedPayment = await classAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' }); + assert.equal(superPaymentList.data.canUpdatePayment, true, '超级管理员应能修改全局范围缴费状态'); + assert.equal(schoolPaymentList.data.canUpdatePayment, true, '校级管理员应能修改本校范围缴费状态'); + assert.ok(superPaymentList.data.registrations.find(item => item.id === registrationId)?.gradeName, '缴费名单应提供年级字段用于筛选'); + const superConfirmedPayment = await admin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } }); + assert.equal(superConfirmedPayment.response.status, 200, '超级管理员应能将负责范围考生标记为已缴费'); + const schoolRevertedPayment = await schoolAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'unpaid' } }); + assert.equal(schoolRevertedPayment.response.status, 200, '校级管理员应能将本校考生改回待缴费'); + assert.equal(schoolRevertedPayment.data.payment.paidAt, null, '改回待缴费后应清除原确认时间'); + assert.equal(schoolRevertedPayment.data.payment.paidBy, null, '改回待缴费后应清除原确认人'); + const confirmedPayment = await classAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } }); assert.equal(confirmedPayment.response.status, 200, '班级负责人应能在线下收款后确认缴费'); assert.equal(confirmedPayment.data.payment.status, 'paid'); assert.ok(confirmedPayment.data.payment.paidAt && confirmedPayment.data.payment.paidByName, '缴费确认应记录时间和办理人'); @@ -950,7 +957,7 @@ try { console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); - console.log('✓ 班级负责人线下缴费确认、状态同步与三级名单导出'); + console.log('✓ 三级管理员范围内缴费状态修改、状态同步与名单导出'); console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); } finally {