diff --git a/app.js b/app.js
index 471d033..5230b24 100644
--- a/app.js
+++ b/app.js
@@ -24,14 +24,22 @@ function toast(title, message = '') {
}
function setModal(content) {
+ document.body.classList.remove('review-subpage-open');
modalRoot.innerHTML = `
`;
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
}
+function setReviewSubpage(content) {
+ document.body.classList.add('review-subpage-open');
+ modalRoot.innerHTML = `${content}
`;
+ setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30);
+}
+
function closeModal() {
const editor = noticeEditor;
noticeEditor = null;
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
+ document.body.classList.remove('review-subpage-open');
modalRoot.innerHTML = '';
}
@@ -104,14 +112,47 @@ function updateRegistrationSelection() {
document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; });
}
+function updateCandidateSelection() {
+ const table = document.querySelector('#candidateTable');
+ if (!table) return;
+ const selectable = [...table.querySelectorAll('[data-candidate-select]:not(:disabled)')];
+ const visible = selectable.filter(input => !input.closest('tr').hidden);
+ const selected = selectable.filter(input => input.checked);
+ const selectAll = table.querySelector('[data-candidate-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-candidate-selection-count]');
+ if (count) count.textContent = selected.length;
+ document.querySelectorAll('[data-action="bulk-candidate-review"]').forEach(button => { button.disabled = selected.length === 0; });
+}
+
+function updatePaymentSelection() {
+ const table = document.querySelector('#paymentTable');
+ if (!table) return;
+ const selectable = [...table.querySelectorAll('[data-payment-select]:not(:disabled)')];
+ const visible = selectable.filter(input => !input.closest('tr').hidden);
+ const selected = selectable.filter(input => input.checked);
+ const selectAll = table.querySelector('[data-payment-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-payment-selection-count]');
+ if (count) count.textContent = selected.length;
+ document.querySelectorAll('[data-action="bulk-payment-update"]').forEach(button => { button.disabled = selected.length === 0; });
+}
+
function updateQualificationSelection(container) {
if (!container) return;
const rows = [...container.querySelectorAll('[data-qualification-select]')];
+ const visible = rows.filter(input => !input.closest('tr').hidden);
const selected = rows.filter(input => input.checked);
const selectAll = container.querySelector('[data-action="qualification-select-all"]');
if (selectAll) {
- selectAll.checked = rows.length > 0 && selected.length === rows.length;
- selectAll.indeterminate = selected.length > 0 && selected.length < rows.length;
+ selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
+ selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
}
const count = container.querySelector('[data-qualification-selected-count]');
if (count) count.textContent = `已选 ${selected.length} 人`;
@@ -124,13 +165,16 @@ function applyTableFilters(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 => {
+ table.querySelectorAll('[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);
+ const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '').split('|').includes(select.value));
row.hidden = !(matchesSearch && matchesStatus && matchesFilters);
});
if (tableId === 'registrationTable') updateRegistrationSelection();
+ if (tableId === 'candidateTable') updateCandidateSelection();
+ if (tableId === 'paymentTable') updatePaymentSelection();
+ if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger'));
}
async function refreshPublic() {
@@ -257,10 +301,20 @@ document.addEventListener('click', async event => {
}
if (action === 'qualification-select-all') {
const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger');
- container?.querySelectorAll('[data-qualification-select]').forEach(input => { input.checked = target.checked; });
+ container?.querySelectorAll('[data-qualification-select]').forEach(input => {
+ if (!input.closest('tr').hidden) input.checked = target.checked;
+ });
updateQualificationSelection(container);
return;
}
+ if (action === 'clear-table-filters') {
+ const tableId = target.dataset.target;
+ document.querySelectorAll(`[data-table-filter][data-target="${tableId}"]`).forEach(select => { select.value = ''; });
+ document.querySelectorAll(`[data-action="table-search"][data-target="${tableId}"]`).forEach(input => { input.value = ''; });
+ document.querySelectorAll(`[data-action="status-filter"][data-target="${tableId}"]`).forEach((button, index) => button.classList.toggle('active', index === 0));
+ applyTableFilters(tableId);
+ return;
+ }
if (action === 'bulk-indicator-qualification') {
const toolbar = target.closest('[data-qualification-bulk]');
const ledger = toolbar?.closest('.qualification-ledger');
@@ -405,6 +459,24 @@ document.addEventListener('click', async event => {
toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新');
return renderRoute();
}
+ if (action === 'bulk-candidate-review') {
+ const ids = [...document.querySelectorAll('#candidateTable [data-candidate-select]:checked')].map(input => input.value);
+ if (!ids.length) return toast('请先选择考生', '仅当前步骤可由你处理的资料可以勾选');
+ const status = target.dataset.status;
+ const reviewNote = window.prompt(status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)', '');
+ 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/candidates/${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' });
@@ -421,6 +493,25 @@ document.addEventListener('click', async event => {
toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除');
return renderRoute();
}
+ if (action === 'bulk-payment-update') {
+ const selected = [...document.querySelectorAll('#paymentTable [data-payment-select]:checked')];
+ if (!selected.length) return toast('请先选择缴费记录');
+ const status = target.dataset.status;
+ const changed = selected.filter(input => input.dataset.status !== status);
+ const skipped = selected.length - changed.length;
+ if (!changed.length) return toast('所选记录无需更改', status === 'paid' ? '所选考生都已缴费' : '所选考生都处于待缴费状态');
+ if (!window.confirm(`确认将 ${changed.length} 条缴费记录批量改为“${status === 'paid' ? '已缴费' : '待缴费'}”吗?`)) return;
+ let completed = 0;
+ const failures = [];
+ for (const input of changed) {
+ try {
+ await api(`/api/admin/payments/${input.value}`, { method: 'PATCH', body: { status } });
+ completed += 1;
+ } catch (error) { failures.push(error.message); }
+ }
+ toast(`已更新 ${completed} 条缴费记录`, [skipped ? `${skipped} 条状态相同已跳过` : '', failures.length ? `${failures.length} 条未完成` : ''].filter(Boolean).join(';'));
+ return renderRoute();
+ }
if (action === 'excel-download') {
const query = new URLSearchParams();
if (target.dataset.template === '1') query.set('template', '1');
@@ -510,6 +601,8 @@ document.addEventListener('change', event => {
if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger'));
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-candidate-select]')) updateCandidateSelection();
+ if (event.target.matches('[data-payment-select]')) updatePaymentSelection();
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 => {
@@ -517,6 +610,20 @@ document.addEventListener('change', event => {
});
updateRegistrationSelection();
}
+ if (event.target.matches('[data-candidate-select-all]')) {
+ const table = document.getElementById(event.target.dataset.target);
+ table?.querySelectorAll('[data-candidate-select]:not(:disabled)').forEach(input => {
+ if (!input.closest('tr').hidden) input.checked = event.target.checked;
+ });
+ updateCandidateSelection();
+ }
+ if (event.target.matches('[data-payment-select-all]')) {
+ const table = document.getElementById(event.target.dataset.target);
+ table?.querySelectorAll('[data-payment-select]:not(:disabled)').forEach(input => {
+ if (!input.closest('tr').hidden) input.checked = event.target.checked;
+ });
+ updatePaymentSelection();
+ }
if (event.target.matches('[data-excel-file]')) {
const input = event.target;
const file = input.files?.[0];
@@ -919,8 +1026,11 @@ function openFlowDetail(id) {
function openCandidateReview(id) {
const source = state.pageData.candidates.find(candidate => candidate.id === id);
const item = { ...source, address: formatRegionAddress(source) };
- const canReview = item.status === 'pending' && item.workflow?.assignee?.id === state.user.id;
- setModal(`CANDIDATE REVIEW${canReview ? '处理' : '查看'} ${h(item.name)} 的资料
报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}
- 证件号码
- ${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 ? `` : ''}`);
+ const canReview = item.status === 'pending' && (item.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
+ const registrations = item.registrations || [];
+ const examCards = registrations.map(registration => `- 考试时间
- ${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}
- 考试地点
- ${h(registration.exam?.location || '待公布')}
- 报名科目
${registration.subjects?.map(subject => `${h(subject.name)}${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}`).join('') || '未选择科目'}
- 报名 / 缴费
- ${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}
`).join('');
+ const decision = canReview ? `` : ``;
+ setReviewSubpage(`- 证件号码
- ${h(item.idNumberMasked)}
- 性别 / 出生日期
- ${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}
- 籍贯 / 民族
- ${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}
- 就读学校 / 班级
- ${h(item.school || '未填写')} · ${h(item.grade || '未填写')}
- 联系电话
- ${h(item.phone || '未填写')}
- 电子邮箱
- ${h(item.email || '未填写')}
- 家庭住址
- ${h(item.address || '未填写')}
- 监护人
- ${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}
- 紧急联系人
- ${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}
02关联考试与报名科目
审核资料时同时查看该考生历次报名上下文。
${registrations.length} 场${examCards || '
暂无考试报名该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。
'}
${decision}
`);
}
function openCandidatePasswordReset(id) {
@@ -932,7 +1042,10 @@ 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 || state.user.adminLevel === 'super');
- setModal(`REGISTRATION REVIEW${canReview ? '处理' : '查看'}考试报名
${h(reg.candidate?.name)} · ${h(reg.exam.name)}
报考科目${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 ? `` : ''}`);
+ const subjects = reg.subjects || [];
+ const subjectCards = subjects.map((subject, index) => `${index + 1}${h(subject.name)}${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `—${h(subject.end)}` : ''}
- 满分
- ${h(subject.fullScore)} 分
- 报名费
- ${money(subject.fee || 0)}
`).join('');
+ const decision = canReview ? `` : ``;
+ setReviewSubpage(`考试${h(reg.exam.code)}
${h(reg.exam.name)}
- 报名时间
- ${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}
- 考试时间
- ${dateRange(reg.exam.examStart, reg.exam.examEnd)}
- 考试地点
- ${h(reg.exam.location || '待公布')}
- 本次报名
- ${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}
- 缴费状态
- ${badge(reg.paymentStatus)}
- 账户报名号
- ${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}
- 姓名 / 性别
- ${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}
- 证件号码
- ${h(reg.candidate?.idNumber || '未填写')}
- 学校 / 班级
- ${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}
- 联系电话
- ${h(reg.candidate?.phone || '未填写')}
${decision}
`);
}
async function openNoticeForm() {
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 8055568..207e3b2 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -106,29 +106,51 @@ export function createAdminViews(context) {
function adminCandidates(data) {
const candidates = data.candidates || [];
- const readOnly = false;
+ const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('candidates.review');
+ const classById = new Map((data.classes || []).map(item => [item.id, item]));
+ const optionList = getter => [...new Set(candidates.map(getter).filter(Boolean))]
+ .sort((left, right) => left.localeCompare(right, 'zh-CN'))
+ .map(value => ``).join('');
const archiveConsole = state.user.adminLevel === 'school' ? (() => {
const classes = data.classes || [];
const grades = [...new Set(classes.map(item => item.grade))];
return `SCHOOL ACCOUNT ARCHIVE按班级或年级归档账户
归档只冻结登录,不删除考生、报名、准考证、成绩和审计记录;可随时按相同范围恢复。
`;
})() : '';
- return `${archiveConsole}${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}`;
+ const rows = candidates.map(item => {
+ const schoolClass = classById.get(item.classId);
+ const grade = schoolClass?.grade || '';
+ const className = schoolClass?.name || item.grade || '';
+ const exams = item.registrations || [];
+ const canSelect = canReview && item.profileCompleted && !item.accountArchived && item.status === 'pending' && item.workflow?.status === 'pending'
+ && (state.user.adminLevel === 'super' || item.workflow?.assignee?.id === state.user.id);
+ const examNames = exams.map(registration => registration.exam?.name).filter(Boolean);
+ return ` | ${h(item.name.slice(0,1))}${h(item.name)}${h(item.candidateNumber || '待分配')} | ${h(item.idNumberMasked)} | ${h(item.school || '未填写')}${h([grade, className].filter(Boolean).join(' · '))} | ${exams.length ? `${h(examNames.slice(0, 2).join('、'))}${exams.length > 2 ? ` 等 ${exams.length} 场` : ''}${h(exams.flatMap(registration => registration.subjects || []).map(subject => subject.name).slice(0, 5).join('、') || '尚未选择科目')}` : '暂无考试报名'} | ${item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : item.profileCompleted ? '正常' : '待补全资料'}${item.accountArchived ? `${formatDate(item.archivedAt, true)} · ${h(item.archivedByName || '校方')}` : h(item.workflow?.assignee?.displayName || '')} | ${item.profileCompleted ? badge(item.status) : '未完成'} | ${state.user.adminLevel === 'super' ? `` : ''} |
`;
+ }).join('');
+ const bulkToolbar = canReview ? `已选择 0 名当前可处理考生 ` : '';
+ const examOptions = [...new Set(candidates.flatMap(item => (item.registrations || []).map(registration => registration.exam?.name).filter(Boolean)))]
+ .sort((left, right) => left.localeCompare(right, 'zh-CN'))
+ .map(value => ``).join('');
+ const filters = ``;
+ return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}`;
}
function adminRegistrations(registrations) {
const isSuper = state.user.adminLevel === 'super';
+ const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('registrations.review');
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 ? '只读封存' : ``} |
`;
+ const canSelect = selectable && reg.status === 'pending' && reg.workflow?.status === 'pending'
+ && (isSuper || reg.workflow?.assignee?.id === state.user.id);
+ 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.subjects.length)} 科 · ${money(reg.amountDue || 0)} | ${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) => ``;
const current = registrations.filter(reg => !reg.exam.archivedAt);
const archived = registrations.filter(reg => reg.exam.archivedAt);
- const bulkToolbar = isSuper ? `` : '';
- return `${bulkToolbar}${table(current, 'registrationTable', isSuper)}${archived.length ? `归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}
${table(archived, 'archivedRegistrationTable')} ` : ''}`;
+ const subjectOptions = [...new Set(registrations.flatMap(item => item.subjects.map(subject => subject.name)))].sort((left, right) => left.localeCompare(right, 'zh-CN')).map(value => ``).join('');
+ const bulkToolbar = canReview ? `已选择 0 条当前可处理报名 ` : '';
+ return `${bulkToolbar}${table(current, 'registrationTable', Boolean(canReview))}${archived.length ? `归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}
${table(archived, 'archivedRegistrationTable')} ` : ''}`;
}
function adminPayments(data) {
@@ -140,9 +162,10 @@ export function createAdminViews(context) {
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 rows = registrations.map(item => `${data.canUpdatePayment ? ` | ` : ''}${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)}
`;
+ const bulkToolbar = data.canUpdatePayment ? `已选择 0 条缴费记录 ` : '';
+ return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}报名人数${registrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
`;
}
function adminExams(exams) {
@@ -177,16 +200,19 @@ export function createAdminViews(context) {
function adminIndicatorQualifications(data) {
if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。');
- return `SOURCE SCHOOL CERTIFICATION${h(data.school?.name)}资格确认簿
逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。
${data.exams.map(item => {
+ return `SOURCE SCHOOL CERTIFICATION${h(data.school?.name)}资格确认簿
先按姓名、报名号、确认状态或特长类型筛选,再逐人确认或多选批量设置。每场考试全部确认后立即自动公示。
${data.exams.map((item, index) => {
const status = item.qualificationStatus;
- const bulk = ``;
- const rows = status.rows.map(row => ` | ${h(row.name)}${h(row.registrationNumber)} | ${h(row.specialtyLabel)} | | ${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'} | |
`).join('');
- return `
${status.complete ? '✓ 本校资格已全部确认,公开公示已自动发布
' : '未全部确认前不会公开,请逐项核对。
'}${bulk}`;
+ const tableId = `qualificationTable-${index}`;
+ const specialties = [...new Set(status.rows.map(row => row.specialtyLabel).filter(Boolean))].sort((left, right) => left.localeCompare(right, 'zh-CN'));
+ const filters = ``;
+ const bulk = ``;
+ const rows = status.rows.map(row => ` | ${h(row.name)}${h(row.registrationNumber)} | ${h(row.specialtyLabel)} | | ${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'} | |
`).join('');
+ return `
${status.complete ? '✓ 本校资格已全部确认,公开公示已自动发布
' : '未全部确认前不会公开,请逐项核对。
'}${filters}${bulk}`;
}).join('')}`;
}
function adminNotices(notices) {
- return ``;
+ return ``;
}
function adminAdmit(data) {
@@ -280,13 +306,15 @@ export function createAdminViews(context) {
function adminFlows(data) {
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领', score_appeal: '考生成绩复议' };
- return `${data.instances.map(instance => {
+ const visibleTypes = [...new Set(data.instances.map(instance => instance.businessType))];
+ const toolbar = `
`;
+ return `${toolbar}
${data.instances.map(instance => {
const isCenter = instance.businessType === 'center_change';
const isBatch = instance.businessType === 'candidate_account_batch';
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])}
`;
+ 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])}
`;
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`;
}
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 1675041..6037d58 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -861,6 +861,10 @@ export function createAdminRoutes(context) {
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
const account = db.users.find(item => item.id === profile.userId);
+ const registrations = db.registrations
+ .filter(item => item.userId === profile.userId)
+ .map(item => examRegistrationView(db, item))
+ .sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0));
return {
...profile,
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
@@ -870,6 +874,7 @@ export function createAdminRoutes(context) {
accountArchived: Boolean(account?.archivedAt),
archivedAt: account?.archivedAt || null,
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
+ registrations,
workflow: workflowView(db, instance)
};
});
diff --git a/styles.css b/styles.css
index 5d3afb0..7249b6a 100644
--- a/styles.css
+++ b/styles.css
@@ -682,3 +682,98 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; }
.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; }
@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } }
+
+/* 审核工作台:以完整子页面承载资料、考试和处理结论 */
+body.review-subpage-open { overflow:hidden; }
+.review-subpage-layer { position:fixed; inset:0; z-index:120; overflow:auto; background:#eef2f6; animation:fadeIn .16s ease; }
+.review-subpage { min-height:100vh; color:#27364a; background:linear-gradient(90deg,#f8fafc 0 72%,#edf2f5 72%); }
+.review-subpage-header { min-height:190px; display:grid; grid-template-columns:minmax(190px,1fr) minmax(420px,2.2fr) minmax(140px,1fr); align-items:center; gap:28px; padding:34px clamp(28px,5vw,76px); color:#fff; background:linear-gradient(112deg,#132f52 0 62%,#216b79); box-shadow:0 12px 35px rgba(23,55,95,.16); }
+.review-back { justify-self:start; align-self:start; padding:10px 0; border:0; color:#c5dbe4; background:transparent; font-size:14px; font-weight:700; cursor:pointer; }
+.review-back:hover { color:#fff; }
+.review-subpage-header > div > span { color:#9bd0d6; font:700 12px/1.3 Consolas,monospace; letter-spacing:.16em; }
+.review-subpage-header h1 { margin:9px 0 12px; font:500 clamp(28px,3vw,42px)/1.18 STKaiti,KaiTi,serif; }
+.review-subpage-header p { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0; color:#d2dce7; font-size:14px; }
+.review-subpage-header p i { width:4px; height:4px; border-radius:50%; background:#7fa8b8; }
+.review-subpage-header > .status { justify-self:end; min-width:82px; justify-content:center; padding:9px 14px; font-size:13px; }
+.review-subpage-layout { width:min(1500px,100%); display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:28px; margin:0 auto; padding:32px clamp(24px,4vw,58px) 70px; }
+.review-subpage-main { min-width:0; display:grid; align-content:start; gap:24px; }
+.review-section { overflow:hidden; border:1px solid #dbe3ea; border-radius:14px; background:#fff; box-shadow:0 9px 28px rgba(28,52,76,.05); }
+.review-section > header { min-height:86px; display:flex; align-items:center; gap:16px; padding:20px 24px; border-bottom:1px solid #e3e9ee; background:linear-gradient(90deg,#fff,#f6f9fa); }
+.review-section > header > span { min-width:45px; height:34px; display:grid; place-items:center; border-radius:6px; color:#fff; background:#245783; font:700 12px Consolas,monospace; }
+.review-section > header > div { min-width:0; margin-right:auto; }
+.review-section > header h2 { margin:0; color:#21364e; font-size:21px; }
+.review-section > header p { margin:5px 0 0; color:#748396; font-size:14px; }
+.review-section > header > strong { color:#287486; font-size:15px; }
+.review-detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; margin:0; background:#e5eaef; }
+.review-detail-grid > div { min-width:0; padding:17px 20px; background:#fff; }
+.review-detail-grid > div.wide { grid-column:1/-1; }
+.review-detail-grid dt { margin-bottom:7px; color:#798799; font-size:13px; }
+.review-detail-grid dd { margin:0; overflow-wrap:anywhere; color:#293b51; font-size:15px; line-height:1.65; }
+.review-exam-list { display:grid; gap:14px; padding:20px 22px 24px; }
+.review-exam-card { overflow:hidden; border:1px solid #dfe6eb; border-radius:11px; background:#fbfcfd; }
+.review-exam-card > header { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border-bottom:1px solid #e4e9ed; background:#fff; }
+.review-exam-card h3 { margin:5px 0 0; font-size:18px; }
+.review-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:0; margin:0; }
+.review-exam-card dl > div { padding:15px 18px; border-top:1px solid #e8edf0; }
+.review-exam-card dl > div:nth-child(-n+2) { border-top:0; }
+.review-exam-card dt { color:#7d8997; font-size:13px; }
+.review-exam-card dd { margin:6px 0 0; color:#33465a; font-size:14px; line-height:1.6; }
+.review-subject-list { display:flex; flex-wrap:wrap; gap:8px; }
+.review-subject-list > span { display:grid; gap:3px; padding:9px 11px; border:1px solid #d9e3e7; border-radius:8px; background:#fff; }
+.review-subject-list small { color:#738395; font-size:12px; }
+.review-empty-context { padding:25px; border:1px dashed #cdd8df; border-radius:10px; color:#67788b; background:#f7fafb; text-align:center; }
+.review-empty-context strong { color:#33485e; font-size:16px; }
+.review-empty-context p { margin:7px 0 0; font-size:14px; }
+.review-decision-form,.review-readonly { position:sticky; top:24px; align-self:start; display:grid; gap:18px; padding:24px; border-top:5px solid #287486; border-radius:12px; background:#fff; box-shadow:0 14px 38px rgba(26,52,76,.12); }
+.review-decision-form > div { display:grid; gap:5px; padding-bottom:18px; border-bottom:1px solid #e1e7eb; }
+.review-decision-form > div span,.review-decision-form label > span { color:#708093; font-size:13px; font-weight:700; }
+.review-decision-form > div strong { color:#1f3853; font-size:18px; }
+.review-decision-form > div small { color:#7e8a99; font-size:13px; }
+.review-decision-form label { display:grid; gap:7px; }
+.review-decision-form select,.review-decision-form textarea { width:100%; padding:11px 12px; border:1px solid #cbd6df; border-radius:8px; color:#293b50; background:#fff; font-size:14px; line-height:1.6; }
+.review-decision-form select:focus,.review-decision-form textarea:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); }
+.review-decision-form .solid-button { min-height:46px; font-size:14px; }
+.review-readonly strong { color:#233c56; font-size:18px; }.review-readonly p { margin:0; color:#697b8e; font-size:14px; line-height:1.7; }
+.exam-context-hero { border-top:5px solid #245783; }
+.subject-review-section > footer { display:flex; align-items:baseline; justify-content:flex-end; gap:14px; padding:18px 24px; border-top:1px solid #e1e7eb; background:#f8fafb; }
+.subject-review-section > footer span { color:#6d7e91; font-size:14px; }.subject-review-section > footer strong { color:#17375f; font-size:24px; }
+.review-subject-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:13px; padding:20px 22px; }
+.review-subject-cards > article { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:12px; padding:16px; border:1px solid #dbe4ea; border-radius:10px; background:#fff; }
+.review-subject-cards > article > i { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#287486; font-size:13px; font-style:normal; }
+.review-subject-cards > article > div { display:grid; gap:4px; min-width:0; }.review-subject-cards > article > div strong { font-size:16px; }.review-subject-cards > article > div span { color:#718194; font-size:13px; }
+.review-subject-cards dl { display:flex; gap:16px; margin:0; }.review-subject-cards dl div { display:grid; gap:4px; }.review-subject-cards dt { color:#8290a0; font-size:12px; }.review-subject-cards dd { margin:0; font-size:14px; font-weight:700; }
+
+/* 高频台账筛选与批量操作 */
+.candidate-toolbar { flex-wrap:wrap; }.table-filter-selects.five-columns { grid-template-columns:repeat(5,minmax(120px,1fr)); }
+.candidate-exam-summary { min-width:190px; }.candidate-exam-summary strong,.candidate-exam-summary small { display:block; }.candidate-exam-summary span { color:#8793a2; }
+.registration-exam-cell { min-width:220px; }.subject-summary { display:flex; flex-wrap:wrap; gap:5px; margin:7px 0 4px; }.subject-summary span { padding:4px 7px; border-radius:5px; color:#36576e; background:#e9f1f4; font-size:12px; font-weight:700; }.subject-summary em { color:#a14c43; font-style:normal; }
+.qualification-filter-toolbar { flex-wrap:wrap; margin-top:14px; border-top:1px solid #e4e9ed; }.qualification-specialty-filter { flex:1 0 100%; display:flex; gap:9px; }.qualification-specialty-filter select { flex:1; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }.qualification-specialty-filter .row-action { padding:0 12px; border:1px solid #d6dfe5; border-radius:7px; background:#fff; }
+.workflow-filter-toolbar { flex-wrap:wrap; margin-bottom:16px; }.workflow-type-filter { flex:1 0 100%; }.workflow-type-filter select { width:100%; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }
+
+/* 全站可读性下限:正文 14px,辅助信息不低于 12px */
+.admin-readable .portal-sidebar nav button { font-size:14px; }.admin-readable .portal-sidebar nav button em { font-size:12px; }
+.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:14px; }
+.admin-readable .portal-user > span small,.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:12px; }
+.admin-readable .portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
+.admin-readable .portal-content small { font-size:12px !important; line-height:1.55; }
+.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }
+.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:14px !important; }
+.admin-readable .portal-content .status,.admin-readable .portal-content .overline,.admin-readable .portal-content .exam-code { font-size:12px !important; }
+.admin-readable .portal-content :where(.solid-button,.ghost-button,.row-action) { min-height:38px; }
+.portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
+.portal-content small { font-size:12px !important; line-height:1.55; }.portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }.portal-content .status,.portal-content .overline,.portal-content .exam-code { font-size:12px !important; }
+.portal-sidebar nav button { font-size:14px; }.portal-sidebar nav button em,.portal-user small,.sidebar-help small { font-size:12px; }.portal-topbar button { font-size:13px; }
+.auth-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.auth-page span:not(:has(svg)) { font-size:14px; }.auth-page small { font-size:12px; }
+.onboarding-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.onboarding-page span:not(:has(svg)) { font-size:14px; }.onboarding-page small { font-size:12px; }
+.modal-card :where(p,dt,dd,label > span,button,input,select,textarea,li) { font-size:14px; line-height:1.65; }.modal-card span:not(:has(svg)) { font-size:14px; }.modal-card small,.modal-head span { font-size:12px; }
+.public-footer p,.public-footer > span { font-size:13px; line-height:1.65; }
+.notice-document table th,.notice-document table td { padding:15px 17px; font-size:15px; line-height:1.55; }
+.notice-document .document-lead { font-size:15px; }.notice-document .qualification-result { min-width:46px; padding:6px 11px; font-size:14px; }
+.public-main :where(button,input,select,textarea) { font-size:14px; }.public-main :where(p,dt,dd) { font-size:14px; line-height:1.7; }.public-main span:not(:has(svg)):not(.status) { font-size:14px; }.public-main small { font-size:12px; line-height:1.55; }
+
+@media (max-width:1000px) {
+ .review-subpage { background:#f4f7f9; }.review-subpage-header { grid-template-columns:1fr auto; min-height:0; }.review-subpage-header > div { grid-column:1/-1; grid-row:2; }.review-subpage-header > .status { grid-column:2; grid-row:1; }.review-subpage-layout { grid-template-columns:1fr; }.review-decision-form,.review-readonly { position:static; }.table-filter-selects.five-columns { grid-template-columns:repeat(2,minmax(130px,1fr)); }
+}
+@media (max-width:650px) {
+ .review-subpage-header { padding:24px 20px; }.review-subpage-layout { padding:20px 14px 45px; }.review-detail-grid,.review-exam-card dl { grid-template-columns:1fr; }.review-detail-grid > div.wide { grid-column:auto; }.review-exam-card dl > div:nth-child(2) { border-top:1px solid #e8edf0; }.review-subject-cards { grid-template-columns:1fr; padding:14px; }.review-subject-cards > article { grid-template-columns:32px minmax(0,1fr); }.review-subject-cards dl { grid-column:2; }.qualification-specialty-filter { flex-direction:column; }.table-filter-selects.five-columns { grid-template-columns:1fr; }
+}
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index e3a5d33..b223c87 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -689,6 +689,10 @@ try {
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, '报名审核列表应提供考试、学校、年级和班级筛选字段');
+ assert.deepEqual(adminRegistration.subjects.map(item => item.id), [exam.subjects[0].id, exam.subjects[2].id], '报名审核详情应完整提供考生所报科目');
+ const candidateReviewList = await admin.request('/api/admin/candidates');
+ const candidateReviewItem = candidateReviewList.data.candidates.find(item => item.id === profile.id);
+ assert.ok(candidateReviewItem.registrations.some(item => item.id === registrationId && item.exam?.id === exam.id && item.subjects.length === 2), '考生资料审核应同时提供关联考试和所报科目上下文');
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');