This commit is contained in:
2026-07-21 15:43:51 +08:00 Unverified
parent bca22a3a9e
commit 4929858ec2
5 changed files with 268 additions and 23 deletions
+121 -8
View File
@@ -24,14 +24,22 @@ function toast(title, message = '') {
}
function setModal(content) {
document.body.classList.remove('review-subpage-open');
modalRoot.innerHTML = `<div class="modal-layer" data-modal-backdrop><div class="modal-card" role="dialog" aria-modal="true">${content}</div></div>`;
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
}
function setReviewSubpage(content) {
document.body.classList.add('review-subpage-open');
modalRoot.innerHTML = `<div class="review-subpage-layer"><main class="review-subpage" role="dialog" aria-modal="true">${content}</main></div>`;
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(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
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 => `<article class="review-exam-card"><header><div><span class="exam-code">${h(registration.exam?.code || '')}</span><h3>${h(registration.exam?.name || '考试信息异常')}</h3></div>${badge(registration.exam?.archivedAt ? 'archived' : registration.status)}</header><dl><div><dt>考试时间</dt><dd>${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(registration.exam?.location || '待公布')}</dd></div><div><dt>报名科目</dt><dd><div class="review-subject-list">${registration.subjects?.map(subject => `<span><strong>${h(subject.name)}</strong><small>${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}</small></span>`).join('') || '<em>未选择科目</em>'}</div></dd></div><div><dt>报名 / 缴费</dt><dd>${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}</dd></div></dl></article>`).join('');
const decision = canReview ? `<form class="review-decision-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><div><span>当前审核步骤</span><strong>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(item.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写核验说明;退回时请明确指出需要补充或修改的资料">${h(item.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${item.status === 'pending' ? `本步骤由 ${h(item.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `资料状态:${h(statusLabels[item.status] || item.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回考生资料审核</button><div><span>CANDIDATE DOSSIER</span><h1>${h(item.name)} · 资料审核</h1><p><b class="mono">${h(item.candidateNumber)}</b><i></i>${h(item.school || '学校未填写')} · ${h(item.grade || '班级未填写')}<i></i>更新于 ${formatDate(item.updatedAt, true)}</p></div>${badge(item.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section"><header><span>01</span><div><h2>身份与学籍信息</h2><p>核对实名、学籍范围以及联系方式。</p></div></header><dl class="review-detail-grid"><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 出生日期</dt><dd>${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}</dd></div><div><dt>籍贯 / 民族</dt><dd>${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}</dd></div><div><dt>就读学校 / 班级</dt><dd>${h(item.school || '未填写')} · ${h(item.grade || '未填写')}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone || '未填写')}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email || '未填写')}</dd></div><div class="wide"><dt>家庭住址</dt><dd>${h(item.address || '未填写')}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}</dd></div><div><dt>紧急联系人</dt><dd>${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}</dd></div></dl></section><section class="review-section"><header><span>02</span><div><h2>关联考试与报名科目</h2><p>审核资料时同时查看该考生历次报名上下文。</p></div><strong>${registrations.length} 场</strong></header><div class="review-exam-list">${examCards || '<div class="review-empty-context"><strong>暂无考试报名</strong><p>该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。</p></div>'}</div></section></div>${decision}</div>`);
}
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(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
const subjects = reg.subjects || [];
const subjectCards = subjects.map((subject, index) => `<article><i>${index + 1}</i><div><strong>${h(subject.name)}</strong><span>${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `${h(subject.end)}` : ''}</span></div><dl><div><dt>满分</dt><dd>${h(subject.fullScore)} 分</dd></div><div><dt>报名费</dt><dd>${money(subject.fee || 0)}</dd></div></dl></article>`).join('');
const decision = canReview ? `<form class="review-decision-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><div><span>当前审核步骤</span><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(reg.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回本次报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写审核依据;退回时请说明考试或科目问题">${h(reg.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${reg.status === 'pending' ? `本步骤由 ${h(reg.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `报名状态:${h(statusLabels[reg.status] || reg.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回报名审核</button><div><span>REGISTRATION DOSSIER</span><h1>${h(reg.candidate?.name)} · 报名审核</h1><p><b class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}</b><i></i>${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</p></div>${badge(reg.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section exam-context-hero"><header><span>考试</span><div><p class="exam-code">${h(reg.exam.code)}</p><h2>${h(reg.exam.name)}</h2></div></header><dl class="review-detail-grid"><div><dt>报名时间</dt><dd>${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(reg.exam.examStart, reg.exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(reg.exam.location || '待公布')}</dd></div><div><dt>本次报名</dt><dd><strong>${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}</strong></dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}</dd></div></dl></section><section class="review-section subject-review-section"><header><span>科目</span><div><h2>本次所报科目</h2><p>逐科核对日期、时间、满分和报名费用。</p></div><strong>${subjects.length} 科</strong></header><div class="review-subject-cards">${subjectCards || '<div class="review-empty-context"><strong>未选择任何科目</strong><p>该报名记录数据异常,不应通过审核。</p></div>'}</div><footer><span>费用合计</span><strong>${money(reg.amountDue || 0)}</strong></footer></section><section class="review-section"><header><span>考生</span><div><h2>考生资料摘要</h2><p>报名审核同时核验身份与学校范围。</p></div></header><dl class="review-detail-grid"><div><dt>姓名 / 性别</dt><dd>${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}</dd></div><div><dt>证件号码</dt><dd class="mono">${h(reg.candidate?.idNumber || '未填写')}</dd></div><div><dt>学校 / 班级</dt><dd>${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</dd></div><div><dt>联系电话</dt><dd>${h(reg.candidate?.phone || '未填写')}</dd></div></dl></section></div>${decision}</div>`);
}
async function openNoticeForm() {