Files
Exam-Information-System/app.js
T

1025 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs';
import { createAdmissionViews } from './src/client/admission-views.mjs';
import { admissionCategoryEditor, indicatorAllocationEditor } from './src/client/admission-plan-editor.mjs';
import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
import { specialtyCatalog } from './src/data/specialty-types.mjs';
const app = document.querySelector('#app');
const modalRoot = document.querySelector('#modalRoot');
let toastTimer;
let noticeEditor;
let ckeditorModulePromise;
function toast(title, message = '') {
const element = document.querySelector('#toast');
element.querySelector('strong').textContent = title;
element.querySelector('small').textContent = message;
element.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => element.classList.remove('show'), 2800);
}
function setModal(content) {
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 closeModal() {
const editor = noticeEditor;
noticeEditor = null;
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
modalRoot.innerHTML = '';
}
function loadCKEditor() {
if (!document.querySelector('link[data-ckeditor-styles]')) {
const stylesheet = document.createElement('link');
stylesheet.rel = 'stylesheet';
stylesheet.href = '/vendor/ckeditor5/ckeditor5.css';
stylesheet.dataset.ckeditorStyles = '';
document.head.append(stylesheet);
}
ckeditorModulePromise ||= Promise.all([
import('/vendor/ckeditor5/ckeditor5.js'),
import('/vendor/ckeditor5/translations/zh-cn.js')
]);
return ckeditorModulePromise;
}
function emptyState(title, description, route, action) {
return `<section class="panel empty-panel"><span>${icons.ticket}</span><h2>${h(title)}</h2><p>${h(description)}</p>${route ? `<button class="solid-button" data-route="${h(route)}">${h(action)}</button>` : ''}</section>`;
}
function renderError(error) {
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`;
}
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
const { brand, renderHome, renderAnnouncements, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
function navigate(route) {
location.hash = route;
if (location.hash.slice(1) === route) renderRoute();
}
async function renderRoute() {
closeModal();
window.scrollTo({ top: 0, behavior: 'instant' });
const route = location.hash.slice(1) || 'home';
const [section, page = 'dashboard'] = route.split('/');
if (section === 'home') renderHome();
else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); }
else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page);
else if (section === 'admission_school') await renderAdmission(page);
else navigate('home');
}
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');
}
async function refreshSession() {
const session = await api('/api/auth/me');
state.user = session.user;
state.profile = session.profile;
state.permissions = session.permissions || [];
state.scopeLabel = session.scopeLabel || '';
}
async function finishLogin(data) {
state.user = data.user;
await refreshSession();
closeModal();
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
}
function showRecoveryCodes(codes) {
setModal(`<div class="modal-head"><div><span>RECOVERY CODES</span><h2>保存一次性恢复码</h2><p>手机丢失或验证器不可用时,可使用其中一个恢复码登录。每个只能使用一次。</p></div></div><div class="recovery-code-sheet" data-recovery-codes>${codes.map(code => `<code>${h(code)}</code>`).join('')}</div><div class="security-warning">请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。</div><div class="modal-foot"><button class="ghost-button" type="button" data-action="copy-recovery-codes">复制全部</button><button class="solid-button" type="button" data-action="close-modal-refresh">我已安全保存</button></div>`);
}
document.addEventListener('click', async event => {
const routeTarget = event.target.closest('[data-route]');
if (routeTarget) {
event.preventDefault();
return navigate(routeTarget.dataset.route);
}
if (event.target.matches('[data-modal-backdrop]')) return closeModal();
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
try {
if (action === 'close-modal') return closeModal();
if (action === 'close-modal-refresh') { closeModal(); return renderRoute(); }
if (action === 'copy-recovery-codes') {
const codes = [...document.querySelectorAll('[data-recovery-codes] code')].map(item => item.textContent).join('\n');
await navigator.clipboard.writeText(codes);
return toast('恢复码已复制', '请保存到可信的离线位置');
}
if (action === 'copy-totp-secret') {
const secret = document.querySelector('[data-totp-secret]')?.textContent.replace(/\s/g, '') || '';
await navigator.clipboard.writeText(secret);
return toast('手动密钥已复制');
}
if (action === 'retry') return renderRoute();
if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open');
if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open');
if (action === 'toggle-public-nav') return document.querySelector('.public-header nav')?.classList.toggle('open');
if (action === 'scroll-to') {
event.preventDefault();
if (!document.querySelector(`#${target.dataset.target}`)) { navigate('home'); setTimeout(() => document.querySelector(`#${target.dataset.target}`)?.scrollIntoView({ behavior: 'smooth' }), 80); }
else document.querySelector(`#${target.dataset.target}`).scrollIntoView({ behavior: 'smooth' });
return;
}
if (action === 'logout') {
await api('/api/auth/logout', { method: 'POST' });
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
}
if (action === 'open-notice') {
const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice;
const contentHtml = notice.contentHtml || `<p>${h(notice.content).replace(/\r?\n/g, '</p><p>')}</p>`;
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
}
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
if (action === 'download-admitted-candidates') {
const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value;
if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载');
window.location.href = `/api/admission/placements/export?examId=${encodeURIComponent(examId)}`;
return;
}
if (action === 'admission-plan-review') {
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
if (reviewNote == null) return;
await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute();
}
if (action === 'admission-match') {
if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' });
toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute();
}
if (action === 'admission-finalize') {
if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return;
const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' });
await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute();
}
if (action === 'admission-supplementary') {
const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00', '');
if (!preferenceEnd) return;
await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } });
toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute();
}
if (action === 'withdrawal-review') {
const approved = target.dataset.approved === 'true';
const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', '');
if (reviewNote == null) return;
await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
}
if (action === 'save-indicator-qualification') {
const row = target.closest('tr');
const value = row?.querySelector('[data-indicator-eligible]')?.value;
if (!value) return toast('请选择资格结论', '必须明确选择有资格或无资格');
const data = await api(`/api/admin/indicator-qualifications/${row.dataset.examId}/${row.dataset.userId}`, { method: 'PUT', body: { eligible: value === 'true' } });
toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生');
return renderRoute();
}
if (action === 'add-admission-category') {
const sources = state.pageData?.sourceSchools || [];
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
return;
}
if (action === 'remove-admission-category') {
const list = target.closest('[data-admission-categories]');
if (list?.children.length <= 1) return toast('至少保留一个招生类别');
target.closest('.admission-category-editor')?.remove();
return;
}
if (action === 'add-indicator-allocation') {
const sources = state.pageData?.sourceSchools || [];
target.closest('.indicator-allocation-editor')?.querySelector('[data-indicator-allocations]')?.insertAdjacentHTML('beforeend', indicatorAllocationEditor(h, sources));
return;
}
if (action === 'remove-indicator-allocation') {
target.closest('.indicator-allocation-row')?.remove();
return;
}
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
event.preventDefault();
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
if (!examId) return toast('请选择考试', '没有可导出的考试范围');
const type = { 'batch-admit-download': 'admit-cards', 'admit-info-export': 'info', 'center-materials-export': 'center-materials' }[action];
window.location.href = `/api/admin/admission-exports/${type}?examId=${encodeURIComponent(examId)}`;
return;
}
if (action === 'new-notice') { await openNoticeForm(); return; }
if (action === 'new-exam') return openExamForm();
if (action === 'new-admin') return openAdminForm();
if (action === 'new-school') return openSchoolForm();
if (action === 'edit-school') return openSchoolForm(state.pageData.schools.find(item => item.id === target.dataset.id));
if (action === 'toggle-school') {
const active = target.dataset.active === 'true';
await api(`/api/admin/schools/${target.dataset.id}`, { method: 'PATCH', body: { active } });
toast(active ? '学校已启用' : '学校已停用', active ? '考生公开入口已恢复显示' : '公开入口已隐藏,班级、管理员和历史数据均已保留'); return renderRoute();
}
if (action === 'new-school-class') return openSchoolClassForm();
if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id));
if (action === 'toggle-school-class') {
await api(`/api/admin/classes/${target.dataset.id}`, { method: 'PATCH', body: { active: target.dataset.active === 'true' } });
toast(target.dataset.active === 'true' ? '班级已启用' : '班级已停用', '班级管理员和历史数据仍会保留'); return renderRoute();
}
if (action === 'new-class-admin') return openClassAdminForm(null, target.dataset.classId);
if (action === 'edit-class-admin') {
const schoolClass = state.pageData.classes.find(item => item.id === target.dataset.classId);
return openClassAdminForm(schoolClass?.admins.find(item => item.id === target.dataset.id), target.dataset.classId);
}
if (action === 'new-center') return openCenterForm();
if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id));
if (action === 'add-center-room') {
document.querySelector('[data-center-rooms]')?.insertAdjacentHTML('beforeend', centerRoomEditor());
return;
}
if (action === 'remove-center-room') {
const list = target.closest('[data-center-rooms]');
if (list.children.length <= 1) return toast('至少保留一个考场', '考点档案必须包含结构化考场');
target.closest('.center-room-editor').remove(); return;
}
if (action === 'open-flow') return openFlowDetail(target.dataset.id);
if (action === 'add-workflow-step') {
const form = document.querySelector(`[data-form="workflow-design"][data-type="${target.dataset.type}"]`);
form?.querySelector('[data-workflow-steps]')?.insertAdjacentHTML('beforeend', workflowStepEditor());
return;
}
if (action === 'remove-workflow-step') {
const list = target.closest('[data-workflow-steps]');
if (list.children.length <= 1) return toast('至少保留一步', '审批流程不能为空');
target.closest('.workflow-step-row').remove(); return;
}
if (action === 'add-exam-subject') {
const form = target.closest('form');
const date = form?.examStart?.value?.slice(0, 10) || '';
form?.querySelector('[data-exam-subjects]')?.insertAdjacentHTML('beforeend', examSubjectEditor({ date }));
refreshExamScoringForm(form);
return;
}
if (action === 'remove-exam-subject') {
const form = target.closest('form');
const list = target.closest('[data-exam-subjects]');
if (list.children.length <= 1) return toast('至少保留一个科目', '考试计划必须包含科目');
target.closest('.exam-subject-editor').remove();
refreshExamScoringForm(form);
return;
}
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
if (action === 'reset-candidate-password') return openCandidatePasswordReset(target.dataset.id);
if (action === 'candidate-archive') {
const scope = document.querySelector('[data-archive-scope]')?.value || '';
const separator = scope.indexOf(':');
if (separator < 1) return toast('请选择归档范围', '可以选择一个班级或整个年级');
const archived = target.dataset.archived === 'true';
const data = await api('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: scope.slice(0, separator), scopeValue: scope.slice(separator + 1), archived } });
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');
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
if (target.dataset.examId) query.set('examId', target.dataset.examId);
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
return;
}
if (action === 'excel-import') {
document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click();
return;
}
if (action === 'result-exam-filter') {
state.resultExamFilter = target.dataset.id;
return renderRoute();
}
if (action === 'cancel-result-import') {
state.resultImportPreview = null;
return renderRoute();
}
if (action === 'refresh-results-cache') {
const result = await api('/api/admin/results/cache/refresh', { method: 'POST' });
toast(result.refreshed ? '成绩缓存已刷新' : '成绩缓存未刷新', result.message);
return renderRoute();
}
if (action === 'generate-admit') {
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
if (registration.admitCard) return openAdmitPreview(registration);
return toast('尚未编排', '请使用页面上方的整场编排控制台');
}
if (action === 'preview-arrangement') {
const form = target.closest('form');
const body = formObject(form);
const output = form.parentElement.querySelector('[data-arrangement-preview]');
target.disabled = true;
target.textContent = '正在预检…';
try {
const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement/preview`, { method: 'POST', body });
const summary = data.summary;
output.innerHTML = `<div class="arrangement-preview ${data.warnings.length ? 'warning' : 'ok'}"><strong>${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}</strong><span>${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合</span>${data.warnings.length ? `<ul>${data.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<small>多科目同考点、容量、科目时间和号码唯一性检查均已通过。</small>'}</div>`;
} finally {
target.disabled = false;
target.textContent = '仅预检,不写入';
}
return;
}
if (action === 'toggle-exam') {
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
toast(target.dataset.status === 'published' ? '考试已发布' : '考试已撤回', '公开页面状态已同步'); return refreshPublic().then(renderRoute);
}
if (action === 'archive-exam') {
const confirmed = window.confirm(`确认归档“${target.dataset.name}”吗?\n\n归档不可撤销;本场成绩、Excel 导入和复议改分将永久锁定。`);
if (!confirmed) return;
const result = await api(`/api/admin/exams/${target.dataset.id}/archive`, { method: 'POST' });
state.resultImportPreview = null;
toast('考试已归档', result.message || '全部成绩已永久锁定');
return refreshPublic().then(renderRoute);
}
if (action === 'toggle-notice') {
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
}
if (action === 'status-filter') {
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
applyTableFilters(target.dataset.target);
}
} catch (error) { toast('操作未完成', error.message); }
});
document.addEventListener('input', event => {
if (event.target.matches('[data-action="table-search"]')) {
applyTableFilters(event.target.dataset.target);
}
if (event.target.matches('.subject-options input')) {
const form = event.target.closest('form');
const checked = [...form.querySelectorAll('.subject-options input:checked')];
form.querySelector('[data-subject-count]').textContent = checked.length;
const exam = state.pageData.exams.find(item => item.id === form.examId.value);
const fee = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fee || 0), 0);
const fullScore = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fullScore || 0), 0);
form.querySelector('[data-subject-fee]').textContent = `满分 ${fullScore} · ${money(fee)}`;
}
if (event.target.closest('[data-exam-subjects]') || event.target.matches('[name="passValue"]')) refreshExamScoringForm(event.target.closest('form'));
});
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];
if (!file) return;
const resource = input.dataset.excelFile;
input.value = '';
(async () => {
try {
toast(resource === 'results' ? '正在生成导入预览' : '正在导入 Excel', resource === 'results' ? `${file.name} · 当前不会写入数据库` : `${file.name} · 逐行校验并写入,错误会标出具体行`);
const result = await api(`/api/admin/excel/${resource}`, {
method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer()
});
if (resource === 'results') {
state.resultImportPreview = { ...result, fileName: file.name };
toast(result.summary.invalid ? '预览完成,发现错误' : '预览校验通过', `${result.summary.valid}/${result.summary.total} 行可提交,数据库尚未修改`);
renderRoute();
} else if (resource === 'account_quotas') {
document.querySelectorAll('[data-class-id]').forEach(field => { field.value = '0'; });
result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; });
toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`);
} else {
toast('Excel 导入完成', `已处理 ${result.count} 条数据`); renderRoute();
}
} catch (error) { toast('Excel 导入失败', error.message); }
})();
return;
}
if (event.target.matches('[data-action="school-select"]')) {
const form = event.target.closest('form');
const classSelect = form?.querySelector('select[name="classId"]');
if (classSelect) {
const classes = state.pageData?.classes || state.publicData.classes || [];
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
}
}
if (event.target.matches('[data-action="specialty-category"]')) {
const typeSelect = event.target.closest('.admission-category-editor')?.querySelector('[data-specialty-type]') || event.target.closest('form')?.querySelector('[data-specialty-type]');
const category = specialtyCatalog.find(item => item.code === event.target.value);
if (typeSelect) {
typeSelect.disabled = !category;
typeSelect.innerHTML = `<option value="">${category ? '请选择小类' : '请先选择大类'}</option>${(category?.types || []).map(item => `<option value="${h(item.code)}">${h(item.name)}</option>`).join('')}`;
}
}
if (event.target.matches('[data-action="plan-category-kind"]')) {
const editor = event.target.closest('.admission-category-editor');
const specialtyFields = editor?.querySelector('[data-plan-specialty]');
const enabled = event.target.value === 'specialty';
specialtyFields?.classList.toggle('hidden', !enabled);
specialtyFields?.querySelectorAll('select').forEach(select => { select.disabled = !enabled || (select.hasAttribute('data-specialty-type') && !specialtyFields.querySelector('[name="categorySpecialtyCategory"]')?.value); });
}
if (event.target.matches('[name="categoryName"]')) {
const title = event.target.closest('.admission-category-editor')?.querySelector('header strong');
if (title) title.textContent = event.target.value.trim() || '新类别';
}
if (event.target.matches('[data-action="preference-school"]')) {
const row = event.target.closest('.preference-choice-row');
const categorySelect = row?.querySelector('[name="choiceCategory"]');
const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId);
const plan = admission?.plans?.find(item => item.schoolId === event.target.value);
const preferenceType = row?.dataset.preferenceType || 'general';
if (categorySelect) {
categorySelect.disabled = !plan;
categorySelect.innerHTML = `<option value="">${plan ? '请选择招生类别' : '请先按代码选择学校'}</option>${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => `<option value="${h(item.code)}">${h(item.name)} · 对应余 ${h(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining)}</option>`).join('')}`;
}
}
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
if (event.target.matches('[data-action="admin-level"]')) {
const form = event.target.closest('form');
form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super');
form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class');
}
if (event.target.matches('[data-action="result-exam"]')) {
const form = event.target.closest('form');
const exam = state.pageData?.exams?.find(item => item.id === event.target.value);
const subjectSelect = form?.querySelector('[name="subjectId"]');
const candidateSelect = form?.querySelector('[name="registrationId"]');
if (subjectSelect) subjectSelect.innerHTML = `<option value="">请选择科目</option>${(exam?.subjects || []).map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore ?? '')}" data-pass-text="${h(subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${subject.passValue}%` : `固定 ${subject.passScore} 分`)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}`;
if (candidateSelect) { candidateSelect.innerHTML = '<option value="">请先选择科目</option>'; candidateSelect.disabled = true; }
subjectSelect?.dispatchEvent(new Event('change', { bubbles: true }));
}
if (event.target.matches('[data-action="result-subject"]')) {
const form = event.target.closest('form');
const option = event.target.selectedOptions[0];
const examId = form?.querySelector('[name="examId"]')?.value;
const subjectId = event.target.value;
const fullScore = Number(option?.dataset.fullScore || 0);
const passText = option?.dataset.passText || '';
const scoreInput = form?.querySelector('[name="score"]');
if (scoreInput) scoreInput.max = fullScore || '';
const candidateSelect = form?.querySelector('[name="registrationId"]');
const registrations = (state.pageData?.registrations || []).filter(item => item.examId === examId && item.subjectIds.includes(subjectId));
if (candidateSelect) {
candidateSelect.disabled = !subjectId;
candidateSelect.innerHTML = `<option value="">${subjectId ? '请选择考生' : '请先选择科目'}</option>${registrations.map(item => `<option value="${h(item.id)}">${h(item.candidateName)} · ${h(item.candidateNumber)} · ${h(item.className)}</option>`).join('')}`;
}
const label = form?.querySelector('[data-score-label]');
const hint = form?.querySelector('[data-score-hint]');
if (label) label.textContent = fullScore ? `成绩(0—${fullScore}` : '成绩';
if (hint) hint.textContent = fullScore ? `本科满分 ${fullScore} 分;独立及格规则:${passText}。` : '选择科目后显示其独立及格规则。';
}
if (event.target.matches('[data-action="result-candidate"]')) {
const form = event.target.closest('form');
const result = state.pageData?.results?.find(item => item.registrationId === event.target.value && item.subjectId === form?.subjectId?.value);
if (form?.score) form.score.value = result?.score ?? '';
if (form?.published) form.published.checked = result ? result.published : true;
const button = form?.querySelector('button[type="submit"]');
if (button) button.textContent = result ? '更新这条成绩' : '保存成绩';
}
if (event.target.matches('[name="subjectPassRule"]')) refreshSubjectPassRuleRow(event.target.closest('.exam-subject-editor'));
if (event.target.matches('[name="passPolicy"]')) {
refreshExamScoringForm(event.target.closest('form'));
}
});
document.addEventListener('submit', async event => {
const form = event.target.closest('form[data-form]');
if (!form) return;
event.preventDefault();
const submit = form.querySelector('button[type="submit"]');
const original = submit?.innerHTML;
if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; }
try {
const kind = form.dataset.form;
if (kind === 'login') {
const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) });
if (data.requiresTotp) {
setModal(`<div class="modal-head"><div><span>SECOND STEP</span><h2>输入动态验证码</h2><p>打开验证器应用,输入当前显示的 6 位验证码;也可以使用一个恢复码。</p></div><button data-action="close-modal">×</button></div><form class="modal-form totp-login-form" data-form="totp-login"><input type="hidden" name="challenge" value="${h(data.challenge)}"><label><span>动态验证码或恢复码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required autofocus placeholder="000000"></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>`);
} else await finishLogin(data);
} else if (kind === 'totp-login') {
const data = await api('/api/auth/login/totp', { method: 'POST', body: formObject(form) });
await finishLogin(data);
} else if (kind === 'register') {
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
} else if (kind === 'candidate-password') {
const body = formObject(form);
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body });
await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding');
} else if (kind === 'account-password') {
const body = formObject(form);
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body });
form.reset(); toast('密码修改成功', '下次登录请使用新密码');
} else if (kind === 'totp-setup') {
const data = await api('/api/auth/totp/setup', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>AUTHENTICATOR SETUP</span><h2>扫描二维码</h2><p>在验证器应用中添加账户,然后输入应用显示的 6 位验证码完成绑定。</p></div><button data-action="close-modal">×</button></div><div class="totp-setup-grid"><div class="totp-qr"><img src="${h(data.qrCode)}" alt="TOTP 绑定二维码" width="240" height="240"></div><div class="totp-manual"><span>无法扫码?手动输入密钥</span><code data-totp-secret>${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}</code><button type="button" class="text-button" data-action="copy-totp-secret">复制密钥</button><small>类型:基于时间 · 6 位 · 每 30 秒更新</small></div></div><form class="modal-form totp-confirm-form" data-form="totp-enable"><label><span>验证器中的 6 位验证码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required placeholder="000000"></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>`);
} else if (kind === 'totp-enable') {
const data = await api('/api/auth/totp/enable', { method: 'POST', body: formObject(form) });
state.user = data.user;
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-recovery-codes') {
const data = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: formObject(form) });
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-disable') {
await api('/api/auth/totp/disable', { method: 'POST', body: formObject(form) });
await refreshSession();
toast('二次验证已关闭', '账户现在仅使用密码登录');
renderRoute();
} else if (kind === 'candidate-password-reset') {
const body = formObject(form);
const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' });
setModal(`<div class="modal-head"><div><span>PASSWORD RESET</span><h2>临时密码已生成</h2><p>请通过线下安全渠道交给考生,仅此处展示一次。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>报名号</span><strong>${h(data.candidateNumber)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>原密码和现有登录会话均已失效,考生下次登录必须修改此密码。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
} else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
} else if (kind === 'volunteer-preference') {
const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value, preferenceType: row.dataset.preferenceType || 'general' })).filter(item => item.schoolId && item.categoryCode);
const data = await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
toast(data.locked ? '志愿已保存并锁定' : '志愿已保存', data.locked ? '已达到本轮提交次数上限' : `还可提交 ${data.remainingSubmissions} 次`); renderRoute();
} else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
await api('/api/candidate/registrations', { method: 'POST', body });
toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute();
} else if (kind === 'score-appeal') {
const body = formObject(form);
await api(`/api/candidate/results/${body.resultId}/appeals`, { method: 'POST', body: { reason: body.reason } });
toast('成绩复议已提交', '系统已按班级、学校和考试中心流程自动分配'); renderRoute();
} else if (kind === 'candidate-review') {
const body = formObject(form);
await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body });
closeModal(); toast(body.status === 'approved' ? '资料审核通过' : '资料已退回', '考生端状态已同步'); renderRoute();
} else if (kind === 'registration-review') {
const body = formObject(form);
await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body });
closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute();
} else if (kind === 'admin-form') {
const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
} else if (kind === 'admission-setting') {
const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); body.maxSubmissions = Number(body.maxSubmissions || 3);
await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body });
toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute();
} else if (kind === 'admission-account') {
await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
form.reset(); toast('招生学校账号已创建'); renderRoute();
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
const body = formObject(form);
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
const specialty = editor.querySelector('[name="categoryKind"]').value === 'specialty';
const indicatorAllocations = [...editor.querySelectorAll('.indicator-allocation-row')].map(row => ({ sourceSchoolId: row.querySelector('[name="indicatorSchool"]').value, quota: Number(row.querySelector('[name="indicatorQuota"]').value || 0) })).filter(item => item.sourceSchoolId && item.quota > 0);
return { code: `category_${index + 1}`, name: editor.querySelector('[name="categoryName"]').value.trim(), quota: Number(editor.querySelector('[name="categoryQuota"]').value || 0), isSpecialty: specialty, specialtyCategory: specialty ? editor.querySelector('[name="categorySpecialtyCategory"]').value : '', specialtyType: specialty ? editor.querySelector('[name="categorySpecialtyType"]').value : '', indicatorAllocations };
}).filter(item => item.name && item.quota > 0);
if (!body.categories.length) throw new Error('请至少添加一个有效招生类别');
if (body.categories.some(item => item.isSpecialty && (!item.specialtyCategory || !item.specialtyType))) throw new Error('特长生类别必须同时选择对应的大类和小类');
await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body });
toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
} else if (kind === 'placement-review') {
const body = formObject(form);
await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
} else if (kind === 'school-form') {
const body = formObject(form); body.active = form.active.checked; body.isSourceSchool = form.isSourceSchool.checked; body.isAdmissionSchool = form.isAdmissionSchool.checked;
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute();
} else if (kind === 'school-class') {
const body = formObject(form); body.active = form.active.checked;
await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body });
closeModal(); toast(body.id ? '班级已更新' : '班级已创建', `${body.grade} · ${body.name}`); renderRoute();
} else if (kind === 'class-admin') {
const body = formObject(form); body.active = form.active.checked;
if (body.id) await api(`/api/admin/admins/${body.id}`, { method: 'PATCH', body });
else await api('/api/admin/admins', { method: 'POST', body: { ...body, adminLevel: 'class', schoolId: state.user.schoolId } });
closeModal(); toast(body.id ? '班级管理员已更新' : '班级管理员已创建', '权限范围已绑定到指定班级'); renderRoute();
} else if (kind === 'candidate-account-batch') {
const quotas = [...form.querySelectorAll('[data-class-id]')].map(input => ({ classId: input.dataset.classId, count: Number(input.value || 0) })).filter(item => item.count > 0);
const total = quotas.reduce((sum, item) => sum + item.count, 0);
if (!total) throw new Error('请至少为一个班级填写申领数量');
await api('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas } });
toast('批量申领已提交', `${total} 个账户将在最终批准后统一生成`); renderRoute();
} else if (kind === 'self-registration-setting') {
const enabled = form.enabled.value === 'true';
await api('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled } });
await refreshPublic(); toast(enabled ? '自主注册已开放' : '自主注册已关闭', enabled ? '公开入口现在可以申请报名号' : '仅保留学校下发账户流程'); renderRoute();
} else if (kind === 'center-form') {
const body = formObject(form);
const editing = Boolean(body.id);
body.rooms = [...form.querySelectorAll('.center-room-editor')].map(row => ({
id: row.querySelector('[name="roomId"]').value || null,
code: row.querySelector('[name="roomCode"]').value,
name: row.querySelector('[name="roomName"]').value,
building: row.querySelector('[name="roomBuilding"]').value,
floor: row.querySelector('[name="roomFloor"]').value,
capacity: Number(row.querySelector('[name="roomCapacity"]').value),
seatPlan: row.querySelector('[name="roomSeatPlan"]').value,
roomType: row.querySelector('[name="roomType"]').value,
status: row.querySelector('[name="roomStatus"]').value,
notes: row.querySelector('[name="roomNotes"]').value
}));
await api(editing ? `/api/admin/centers/${body.id}` : '/api/admin/centers', { method: editing ? 'PATCH' : 'POST', body });
closeModal(); toast(editing ? '考点变更已提交' : '新考点已提交', '审批通过后才会更新正式档案'); renderRoute();
} else if (kind === 'number-rule') {
const raw = formObject(form);
const types = Object.keys(numberSegmentMeta);
const segments = types.filter(type => type === 'sequence' || form.querySelector(`[name="include_${type}"]`)?.checked).map(type => ({
type, position: Number(raw[`position_${type}`] || 99), value: raw[`value_${type}`] || '', width: Number(raw[`width_${type}`] || 0)
})).sort((a, b) => a.position - b.position);
await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } });
toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute();
} else if (kind === 'admission-arrangement') {
const body = formObject(form);
const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement`, { method: 'POST', body });
toast('整场编排已生成', `${data.summary.candidateCount} 名考生 · ${data.summary.subjectAssignmentCount} 个科次座位`); renderRoute();
} else if (kind === 'workflow-design') {
const names = [...form.querySelectorAll('[name="stepName"]')];
const levels = [...form.querySelectorAll('[name="stepLevel"]')];
const steps = names.map((input, index) => ({ name: input.value, adminLevel: levels[index].value }));
await api(`/api/admin/workflows/${form.dataset.type}`, { method: 'PUT', body: { name: form.name.value, steps } });
toast('审批流程已保存', `${steps.length} 个步骤已启用`); renderRoute();
} else if (kind === 'flow-process') {
const body = formObject(form);
const path = body.businessType === 'profile_change'
? `/api/admin/candidates/${body.businessId}`
: body.businessType === 'registration_review'
? `/api/admin/registrations/${body.businessId}`
: body.businessType === 'center_change'
? `/api/admin/center-change-requests/${body.businessId}`
: body.businessType === 'candidate_account_batch'
? `/api/admin/candidate-account-batches/${body.businessId}`
: `/api/admin/score-appeals/${body.businessId}`;
await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote, ...(body.reviewedScore == null || body.reviewedScore === '' ? {} : { reviewedScore: Number(body.reviewedScore) }) } });
closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute();
} else if (kind === 'flow-transfer') {
const body = formObject(form);
await api(`/api/admin/workflow-instances/${body.id}/transfer`, { method: 'PATCH', body });
closeModal(); toast('流程已转交', '新责任人已收到待办'); renderRoute();
} else if (kind === 'flow-supervise') {
const body = formObject(form);
await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body });
closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute();
} else if (kind === 'notice-form') {
const body = formObject(form);
if (noticeEditor) body.content = noticeEditor.getData();
body.pinned = form.pinned.checked;
await api('/api/admin/notices', { method: 'POST', body });
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
} else if (kind === 'exam-form') {
const body = formObject(form);
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
name: row.querySelector('[name="subjectName"]').value.trim(),
fullScore: Number(row.querySelector('[name="subjectFullScore"]').value),
passRule: row.querySelector('[name="subjectPassRule"]').value,
passValue: Number(row.querySelector('[name="subjectPassValue"]').value || 0),
date: row.querySelector('[name="subjectDate"]').value,
start: row.querySelector('[name="subjectStart"]').value,
end: row.querySelector('[name="subjectEnd"]').value,
fee: Number(row.querySelector('[name="subjectFee"]').value || 0)
}));
body.passValue = ['subject_scores', 'none'].includes(body.passPolicy) ? 0 : Number(body.passValue);
['registrationStart','registrationEnd','examStart','examEnd','admitDownloadStart','admitDownloadEnd'].forEach(field => body[field] = new Date(body[field]).toISOString());
const editing = Boolean(body.id);
await api(editing ? `/api/admin/exams/${body.id}` : '/api/admin/exams', { method: editing ? 'PATCH' : 'POST', body });
closeModal(); await refreshPublic(); toast(editing ? '考试草稿已更新' : '考试计划已创建', `${body.subjects.length} 个科目已配置`); renderRoute();
} else if (kind === 'result-entry') {
const body = formObject(form); body.published = form.published.checked;
await api('/api/admin/results', { method: 'POST', body });
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
} else if (kind === 'feature-score-entry') {
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute();
} else if (kind === 'result-import-commit') {
const rows = state.resultImportPreview?.rows || [];
if (!rows.length) throw new Error('没有可提交的成绩预览');
const result = await api('/api/admin/results/import', { method: 'POST', body: { rows } });
state.resultImportPreview = null;
toast('批量成绩已写入', `${result.count} 条成绩已在同一事务中提交`); renderRoute();
}
} catch (error) { toast('操作未完成', error.message); }
finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } }
});
function openAdminForm() {
const { schools = [], classes = [] } = state.pageData;
setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></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>`);
}
function openSchoolForm(school = null) {
setModal(`<div class="modal-head"><div><span>SCHOOL DIRECTORY</span><h2>${school ? '编辑学校档案' : '创建学校'}</h2><p>学校代码统一标识学校;同一学校可以同时承担生源校和招生校职责。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-form">${school ? `<input type="hidden" name="id" value="${h(school.id)}">` : ''}<div class="field-row"><label><span>学校名称 *</span><input name="name" required maxlength="100" value="${h(school?.name || '')}" placeholder="例如 海州市第四中学"></label><label><span>学校代码 *</span><input name="code" required maxlength="40" pattern="[A-Za-z0-9_-]+" value="${h(school?.code || '')}" placeholder="例如 HZ04"></label></div><label><span>学校地址</span><input name="address" maxlength="200" value="${h(school?.address || '')}" placeholder="省、市、区县及详细地址"></label><section class="school-role-selector"><strong>学校类型(至少选择一项)</strong><div><label><input type="checkbox" name="isSourceSchool" ${school?.isSourceSchool === false ? '' : 'checked'}><span><b>生源校</b><small>可建立班级、管理考生,并接收指标名额</small></span></label><label><input type="checkbox" name="isAdmissionSchool" ${school?.isAdmissionSchool === false ? '' : 'checked'}><span><b>招生校</b><small>可配置招生账号、上传计划并审核投档</small></span></label></div></section><label class="agreement"><input type="checkbox" name="active" ${school?.active === false ? '' : 'checked'}><span>启用该学校;公开报名入口只显示已启用的生源校</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${school ? '保存学校' : '创建学校'}</button></div></form>`);
}
function openSchoolClassForm(schoolClass = null) {
setModal(`<div class="modal-head"><div><span>SCHOOL CLASS</span><h2>${schoolClass ? '编辑本校班级' : '新增本校班级'}</h2><p>班级是考生资料、报名和成绩权限的最小范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-class">${schoolClass ? `<input type="hidden" name="id" value="${h(schoolClass.id)}">` : ''}<div class="field-row"><label><span>年级 *</span><input name="grade" required value="${h(schoolClass?.grade || '')}" placeholder="例如 高三"></label><label><span>班级名称 *</span><input name="name" required value="${h(schoolClass?.name || '')}" placeholder="例如 高三(3)班"></label></div><label class="agreement"><input type="checkbox" name="active" ${schoolClass?.active === false ? '' : 'checked'}><span>启用该班级,可继续分配考生和班级管理员</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${schoolClass ? '保存班级' : '创建班级'}</button></div></form>`);
}
function openClassAdminForm(admin = null, classId = '') {
const classes = state.pageData.classes || [];
setModal(`<div class="modal-head"><div><span>CLASS SCOPE</span><h2>${admin ? '维护班级管理员' : '添加班级管理员'}</h2><p>该账号可查看并审批所绑定班级的考生、报名与成绩复议流程。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="class-admin">${admin ? `<input type="hidden" name="id" value="${h(admin.id)}">` : ''}<div class="field-row"><label><span>管理员姓名 *</span><input name="displayName" required value="${h(admin?.displayName || '')}"></label><label><span>绑定班级 *</span><select name="classId" required>${classes.map(item => `<option value="${h(item.id)}" ${item.id === (admin?.classId || classId) ? 'selected' : ''}>${h(item.grade)} · ${h(item.name)}</option>`).join('')}</select></label></div>${admin ? `<label><span>登录账号</span><input value="${h(admin.username)}" disabled></label>` : `<label><span>登录账号 *</span><input name="username" required placeholder="建议使用学校代码与班级缩写"></label>`}<label><span>${admin ? '重置密码(留空则不修改)' : '初始密码 *'}</span><input name="password" type="password" ${admin ? '' : 'required minlength="8"'} placeholder="至少 8 位"></label><label class="agreement"><input type="checkbox" name="active" ${admin?.active === false ? '' : 'checked'}><span>启用该管理员账号</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${admin ? '保存管理员' : '创建管理员'}</button></div></form>`);
}
function centerRoomEditor(room = {}) {
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label class="room-notes"><span>座位编排说明</span><input name="roomSeatPlan" value="${h(room.seatPlan || '')}" placeholder="如:按现场桌贴从前至后编排"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
}
function openCenterForm(center = null) {
const schools = state.pageData.schools || [];
const rooms = center?.rooms?.length ? center.rooms : [{}];
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位编排说明与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">提交审批</button></div></form>`);
mountRegionSelects(modalRoot, center);
}
function openFlowDetail(id) {
const instance = state.pageData.instances.find(item => item.id === id);
const currentLevel = instance.currentStepDetail?.adminLevel;
const available = state.pageData.availableAdmins.filter(item => {
if (item.adminLevel !== currentLevel) return false;
if (currentLevel === 'super') return true;
if (currentLevel === 'school') return item.schoolId === instance.assignee?.schoolId;
return item.schoolId === instance.assignee?.schoolId && item.classId === instance.assignee?.classId;
});
const canProcess = instance.status === 'pending' && instance.assignee?.id === state.user.id;
const history = instance.actions.map(action => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).join('');
const isCenter = instance.businessType === 'center_change';
const isBatch = instance.businessType === 'candidate_account_batch';
const isAppeal = instance.businessType === 'score_appeal';
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
const finalAppealStep = isAppeal && instance.currentStep >= instance.steps.length;
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName;
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null;
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
const batch = instance.accountBatch;
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
const appeal = instance.appealResult;
const appealSnapshot = appeal ? `<section class="flow-appeal-snapshot"><header><div><span>复议成绩快照</span><h3>${h(appeal.examName)} · ${h(appeal.subjectName)}</h3></div><b>${h(appeal.score)} / ${h(appeal.fullScore)}</b></header><dl><div><dt>当前等级</dt><dd>${h(appeal.grade)}</dd></div><div><dt>本科排名</dt><dd>第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%</dd></div><div><dt>单科规则</dt><dd>${h(appeal.passText)}</dd></div><div><dt>当前结论</dt><dd>${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}</dd></div></dl></section>` : '';
const reviewedScoreField = finalAppealStep ? `<label><span>复核后分数(批准时必填)</span><input name="reviewedScore" type="number" min="0" max="${h(appeal?.fullScore || 0)}" step="0.5" value="${h(appeal?.score ?? '')}"></label>` : '';
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}${batchSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">${finalBatchStep ? '最终批准并生成账号' : '确认处理'}</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
if (appealSnapshot) modalRoot.querySelector('.modal-head')?.insertAdjacentHTML('afterend', appealSnapshot);
if (finalAppealStep) {
const processForm = modalRoot.querySelector('[data-form="flow-process"]');
const fieldRow = processForm?.querySelector('.field-row');
if (fieldRow) fieldRow.insertAdjacentHTML('beforeend', reviewedScoreField);
const processButton = processForm?.querySelector('button[type="submit"]');
if (processButton) processButton.textContent = '批准并更新成绩';
}
}
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>'}`);
}
function openCandidatePasswordReset(id) {
const item = state.pageData?.candidates?.find(candidate => candidate.id === id);
if (!item) return toast('考生不存在', '请刷新页面后重试');
setModal(`<div class="modal-head"><div><span>RESET PASSWORD</span><h2>重置 ${h(item.name)} 的密码</h2><p class="mono">报名号 ${h(item.candidateNumber)}</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="candidate-password-reset"><input type="hidden" name="id" value="${h(item.id)}"><div class="reset-warning"><strong>重置后立即生效</strong><p>系统将生成临时密码,使原密码及该考生所有现有登录会话失效,并要求下次登录先改密。</p></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认重置</button></div></form>`);
}
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>'}`);
}
async function openNoticeForm() {
setModal(`<div class="modal-head"><div><span>NEW NOTICE</span><h2>发布通知公告</h2><p>发布后将立即显示在公开首页和考生中心。</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form"><div class="field-row"><label><span>通知分类</span><select name="category"><option>报名通知</option><option>考试须知</option><option>考点公告</option><option>成绩通知</option><option>系统公告</option></select></label><label><span>发布方式</span><select name="status"><option value="published">立即发布</option><option value="draft">保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容"></textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned"><span>在公开首页置顶展示</span></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>`);
const source = modalRoot.querySelector('[data-notice-editor]');
try {
const [ckeditor, translation] = await loadCKEditor();
const {
ClassicEditor, AutoImage, AutoLink, BlockQuote, Bold, Essentials, Heading,
Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar,
Italic, Link, List, Paragraph, Table, TableCaption, TableToolbar, Underline
} = ckeditor;
if (!source?.isConnected) return;
noticeEditor = await ClassicEditor.create(source, {
licenseKey: 'GPL',
language: 'zh-cn',
translations: [translation.default],
plugins: [
Essentials, Paragraph, Heading, Bold, Italic, Underline, Link, AutoLink, List, BlockQuote,
Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, AutoImage,
Table, TableToolbar, TableCaption
],
toolbar: {
items: ['heading', '|', 'bold', 'italic', 'underline', '|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'link', 'insertImageViaUrl', 'insertTable', '|', 'undo', 'redo'],
shouldNotGroupWhenFull: true
},
heading: {
options: [
{ model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' },
{ model: 'heading2', view: 'h2', title: '二级标题', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: '三级标题', class: 'ck-heading_heading3' }
]
},
link: { defaultProtocol: 'https://', addTargetToExternalLinks: true },
image: {
toolbar: ['imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative']
},
table: {
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'toggleTableCaption']
},
placeholder: '请输入完整通知内容;图片使用 URL 插入,文件使用超链接'
});
} catch (error) {
console.error('CKEditor failed to load', error);
toast('富文本编辑器加载失败', '已保留普通文本输入,可检查网络后重试');
}
}
function dateTimeLocal(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = number => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function examSubjectEditor(subject = {}) {
const passRule = subject.passRule === 'score_ratio' ? 'rank_percent' : (subject.passRule || 'fixed_score');
const passValue = subject.passValue ?? subject.passScore ?? 60;
return `<article class="exam-subject-editor"><header><span>科目明细</span><button type="button" data-action="remove-exam-subject">移除</button></header><div class="exam-subject-grid"><label class="subject-name-field"><span>科目名称 *</span><input name="subjectName" required value="${h(subject.name || '')}" placeholder="例如:语文"></label><label><span>满分 *</span><input name="subjectFullScore" type="number" min="0.5" max="1000" step="0.5" required value="${h(subject.fullScore ?? 100)}"></label><label><span>单科及格线方式 *</span><select name="subjectPassRule"><option value="fixed_score" ${passRule === 'fixed_score' ? 'selected' : ''}>固定分</option><option value="rank_percent" ${passRule === 'rank_percent' ? 'selected' : ''}>按排名比例</option><option value="none" ${passRule === 'none' ? 'selected' : ''}>不设单科线</option></select></label><label data-subject-pass-value><span data-subject-pass-label>规则数值 *</span><span class="unit-input"><input name="subjectPassValue" type="number" min="0" max="1000" step="0.1" required value="${h(passValue)}"><em data-subject-pass-unit>分</em></span></label><label><span>考试日期 *</span><input name="subjectDate" type="date" required value="${h(subject.date || '')}"></label><label><span>开始 *</span><input name="subjectStart" type="time" required value="${h(subject.start || '09:00')}"></label><label><span>结束 *</span><input name="subjectEnd" type="time" required value="${h(subject.end || '11:00')}"></label><label><span>费用</span><input name="subjectFee" type="number" min="0" step="0.01" value="${h(subject.fee ?? 0)}"></label></div><p class="subject-pass-preview" data-subject-pass-preview></p></article>`;
}
function refreshSubjectPassRuleRow(row) {
if (!row) return;
const ruleSelect = row.querySelector('[name="subjectPassRule"]');
const legacyOption = ruleSelect?.querySelector('option[value="score_ratio"]');
if (legacyOption) { legacyOption.value = 'rank_percent'; legacyOption.textContent = '按排名比例'; }
const rule = ruleSelect?.value || 'fixed_score';
const fullScore = Number(row.querySelector('[name="subjectFullScore"]')?.value || 0);
const valueField = row.querySelector('[data-subject-pass-value]');
const input = row.querySelector('[name="subjectPassValue"]');
const label = row.querySelector('[data-subject-pass-label]');
const unit = row.querySelector('[data-subject-pass-unit]');
const preview = row.querySelector('[data-subject-pass-preview]');
valueField?.classList.toggle('hidden', rule === 'none');
if (input) {
input.disabled = rule === 'none';
input.max = rule === 'rank_percent' ? '100' : String(fullScore || 1000);
input.min = rule === 'rank_percent' ? '0.1' : '0';
}
if (label) label.textContent = rule === 'rank_percent' ? '排名比例 *' : '固定及格分 *';
if (unit) unit.textContent = rule === 'rank_percent' ? '%' : '分';
const value = Number(input?.value || 0);
if (preview) preview.textContent = rule === 'none' ? '本科只展示成绩,不单独判定达线。' : rule === 'rank_percent' ? `本科排名前 ${value}% 达线;分数边界随本次已发布成绩队列变化。` : `本科达到 ${value} 分视为单科达线。`;
}
function refreshExamScoringForm(form) {
if (!form?.matches('[data-form="exam-form"]')) return;
const rows = [...form.querySelectorAll('.exam-subject-editor')];
rows.forEach(refreshSubjectPassRuleRow);
const total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0);
const totalElement = form.querySelector('[data-exam-total]');
const countElement = form.querySelector('[data-exam-subject-count]');
if (totalElement) totalElement.textContent = total;
if (countElement) countElement.textContent = rows.length;
const policySelect = form.querySelector('[name="passPolicy"]');
policySelect?.querySelector('option[value="score_ratio"]')?.remove();
const policy = policySelect?.value || 'rank_percent';
const valueField = form.querySelector('[data-pass-value-field]');
const valueInput = form.querySelector('[name="passValue"]');
const unit = form.querySelector('[data-pass-unit]');
const hint = form.querySelector('[data-pass-hint]');
const hiddenValue = ['subject_scores', 'none'].includes(policy);
if (valueField) valueField.classList.toggle('hidden', hiddenValue);
if (valueInput) {
valueInput.disabled = hiddenValue;
valueInput.max = policy === 'fixed_score' ? String(total || 1000) : '100';
valueInput.min = policy === 'fixed_score' ? '0' : '0.1';
}
if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%';
const hints = {
fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。',
rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。',
subject_scores: '每个科目都必须达到上方配置的单科合格分。',
none: '只展示成绩、总分和排名,不显示合格或未合格。'
};
if (hint) hint.textContent = hints[policy];
}
function openExamForm(exam = null) {
const editing = Boolean(exam);
if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿');
const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }];
const passPolicy = exam?.passPolicy === 'score_ratio' ? 'rank_percent' : (exam?.passPolicy || 'rank_percent');
setModal(`<div class="modal-head"><div><span>${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'}</span><h2>${editing ? '编辑考试草稿' : '创建考试与科目'}</h2><p>先定义科目计分,再选择整场考试的合格判定方式。</p></div><button type="button" data-action="close-modal" aria-label="关闭弹窗">×</button></div><form class="modal-form exam-config-form" data-form="exam-form">${editing ? `<input type="hidden" name="id" value="${h(exam.id)}">` : ''}<section class="exam-form-section"><header><span>01</span><div><h3>基本安排</h3><p>考试名称、开放窗口与考点信息</p></div></header><div class="field-row"><label><span>考试名称 *</span><input name="name" required value="${h(exam?.name)}" placeholder="例如:2027 年春季学业考试"></label><label><span>考试代码</span><input name="code" value="${h(exam?.code)}" placeholder="自动生成或手动填写"></label></div><label><span>考试说明</span><textarea name="description" rows="2" placeholder="报名对象、考试范围等">${h(exam?.description)}</textarea></label><div class="field-row"><label><span>报名开始 *</span><input name="registrationStart" type="datetime-local" value="${dateTimeLocal(exam?.registrationStart)}" required></label><label><span>报名结束 *</span><input name="registrationEnd" type="datetime-local" value="${dateTimeLocal(exam?.registrationEnd)}" required></label></div><div class="field-row"><label><span>考试开始 *</span><input name="examStart" type="datetime-local" value="${dateTimeLocal(exam?.examStart)}" required></label><label><span>考试结束 *</span><input name="examEnd" type="datetime-local" value="${dateTimeLocal(exam?.examEnd)}" required></label></div><div class="field-row"><label><span>准考证下载开始 *</span><input name="admitDownloadStart" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadStart)}" required></label><label><span>准考证下载结束 *</span><input name="admitDownloadEnd" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadEnd)}" required></label></div></section><section class="exam-form-section subject-config-section"><header><span>02</span><div><h3>科目与总分</h3><p>每科独立设置满分、合格分、时间和费用</p></div><aside><small><b data-exam-subject-count>${subjects.length}</b> 科</small><strong><b data-exam-total>0</b> 分</strong></aside></header><div data-exam-subjects>${subjects.map(examSubjectEditor).join('')}</div><button class="add-subject-button" type="button" data-action="add-exam-subject">${icons.plus} 添加科目</button></section><section class="exam-form-section pass-policy-section"><header><span>03</span><div><h3>合格线策略</h3><p>系统会在所有报考科目成绩发布后自动判定</p></div></header><div class="pass-policy-grid"><label><span>判定方式 *</span><select name="passPolicy"><option value="score_ratio" ${passPolicy === 'score_ratio' ? 'selected' : ''}>按总分得分率</option><option value="fixed_score" ${passPolicy === 'fixed_score' ? 'selected' : ''}>固定总分线</option><option value="rank_percent" ${passPolicy === 'rank_percent' ? 'selected' : ''}>总成绩排名前百分比</option><option value="subject_scores" ${passPolicy === 'subject_scores' ? 'selected' : ''}>所有单科均达线</option><option value="none" ${passPolicy === 'none' ? 'selected' : ''}>不判定合格</option></select></label><label data-pass-value-field><span>策略数值 *</span><span class="unit-input"><input name="passValue" type="number" min="0" max="100" step="0.1" value="${h(exam?.passValue ?? 60)}" required><em data-pass-unit>%</em></span></label></div><p class="pass-policy-hint" data-pass-hint></p></section><section class="exam-form-section"><header><span>04</span><div><h3>发布设置</h3><p>草稿可继续维护,发布后对考生开放</p></div></header><div class="field-row"><label><span>考点说明</span><input name="location" value="${h(exam?.location)}" placeholder="例如:全市指定考点"></label><label><span>${editing ? '保存状态' : '创建状态'}</span><select name="status"><option value="draft">保存为草稿</option><option value="published">立即发布</option></select></label></div></section><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存考试' : '创建考试'}</button></div></form>`);
refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]'));
}
function openAdmitPreview(reg) {
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const rows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
return `<tr><td>${h(subject.name)}</td><td>${h(subject.date)} ${h(subject.start)}</td><td class="mono">${h(assignment.examRoomCode || '待定')}</td><td><strong>${h(assignment.roomName || assignment.room || '待定')}</strong><small>场地代码 ${h(assignment.roomCode || '—')}</small></td><td>${h(assignment.building || '楼栋待定')} · ${h(assignment.floor || '楼层待定')}</td><td>${h(assignment.seat || '—')}</td></tr>`;
}).join('');
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证编排明细</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>生成时间</dt><dd>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><table><thead><tr><th>科目</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位</th></tr></thead><tbody>${rows}</tbody></table><p>“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
}
window.addEventListener('hashchange', renderRoute);
window.addEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); });
try {
await Promise.all([refreshPublic(), refreshSession()]);
await renderRoute();
} catch (error) {
renderError(error);
}