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) {
document.body.classList.remove('review-subpage-open');
modalRoot.innerHTML = `
`;
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
}
function setReviewSubpage(content) {
document.body.classList.add('review-subpage-open');
modalRoot.innerHTML = `${content}
`;
setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30);
}
function closeModal() {
const editor = noticeEditor;
noticeEditor = null;
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
document.body.classList.remove('review-subpage-open');
modalRoot.innerHTML = '';
}
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 `${icons.ticket}${h(title)}
${h(description)}
${route ? `` : ''}`;
}
function renderError(error) {
if (error?.status === 401) return requireLogin();
console.error('Page failed to render', error);
const message = error?.status ? error.message : '请求未能完成,请稍后重试或返回首页。';
app.innerHTML = `!页面暂时无法加载
${h(message)}
`;
}
function requireLogin() {
state.user = null;
state.profile = null;
state.permissions = [];
state.scopeLabel = '';
state.pageData = null;
state.resultExamFilter = '';
state.resultSubjectFilter = '';
state.resultExamCatalog = null;
state.authNotice = '登录状态已失效,请重新登录。';
navigate('login');
}
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState };
const { brand, renderHome, renderNoticeCenter, 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 !== 'login') state.authNotice = '';
if (section === 'home') renderHome();
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
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 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 = 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} 人`;
}
function updatePlacementSelection() {
const table = document.getElementById('placementReviewTable');
if (!table) return;
const selectable = [...table.querySelectorAll('[data-placement-select]:not(:disabled)')];
const visible = selectable.filter(input => !input.closest('tr').hidden);
const selected = selectable.filter(input => input.checked);
const selectAll = document.querySelector('[data-placement-select-all][data-target="placementReviewTable"]');
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-placement-selected-count]');
if (count) count.textContent = `已选 ${selected.length} 人`;
}
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('[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] || '').split('|').includes(select.value));
row.hidden = !(matchesSearch && matchesStatus && matchesFilters);
});
if (tableId === 'registrationTable') updateRegistrationSelection();
if (tableId === 'candidateTable') updateCandidateSelection();
if (tableId === 'paymentTable') updatePaymentSelection();
if (tableId === 'placementReviewTable') updatePlacementSelection();
if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger'));
}
function updateResultScoreInput(input) {
const row = input.closest('tr');
const error = row?.querySelector('[data-score-error]');
const value = input.value.trim();
const score = Number(value);
const max = Number(input.max);
const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max);
input.dataset.dirty = String(value !== input.defaultValue);
input.setAttribute('aria-invalid', String(invalid));
row?.classList.toggle('score-row-invalid', invalid);
if (error) error.textContent = invalid ? `须在 0—${max} 之间` : '';
const form = input.closest('form');
const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0;
const count = form?.querySelector('[data-result-dirty-count]');
if (count) count.textContent = dirty ? `${dirty} 条成绩尚未暂存` : '尚无未保存修改';
}
function updateFeatureScoreInput(input) {
const row = input.closest('tr');
const error = row?.querySelector('[data-feature-score-error]');
const value = input.value.trim();
const score = Number(value);
const invalid = value === '' || !Number.isFinite(score) || score < 0 || score > 1000;
input.dataset.dirty = String(value !== input.defaultValue);
input.setAttribute('aria-invalid', String(invalid));
row?.classList.toggle('score-row-invalid', invalid);
if (error) error.textContent = invalid ? '须在 0—1000 之间' : '';
const form = input.closest('form');
const dirty = form?.querySelectorAll('[data-feature-score][data-dirty="true"]').length || 0;
const count = form?.querySelector('[data-feature-dirty-count]');
if (count) count.textContent = dirty ? `${dirty} 条特征分尚未保存` : '尚无未保存修改';
}
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;
state.authNotice = '';
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(`RECOVERY CODES保存一次性恢复码
手机丢失或验证器不可用时,可使用其中一个恢复码登录。每个只能使用一次。
${codes.map(code => `${h(code)}`).join('')}
请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。
`);
}
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 = ''; state.resultExamFilter = ''; state.resultSubjectFilter = ''; state.resultExamCatalog = null;
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
}
if (action === 'open-notice') {
return navigate(`notice/${target.dataset.id}`);
}
if (action === 'notice-category') {
state.noticeCategory = target.dataset.category;
state.noticePage = 1;
renderNoticeCenter(state.publicAnnouncements);
return;
}
if (action === 'notice-page') {
if (target.disabled) return;
state.noticePage = Number(target.dataset.page || 1);
renderNoticeCenter(state.publicAnnouncements);
window.scrollTo({ top: 0, behavior: 'smooth' });
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' ? '招生计划已通过并自动公示' : '招生计划已退回', 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 === 'qualification-select-all') {
const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger');
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');
const userIds = [...ledger.querySelectorAll('[data-qualification-select]:checked')].map(input => input.closest('tr').dataset.userId);
const value = toolbar.querySelector('[data-bulk-eligible]').value;
if (!userIds.length) return toast('请先选择考生', '可使用左侧复选框或全选');
if (!value) return toast('请选择批量资格结论');
if (!window.confirm(`确认将所选 ${userIds.length} 名考生批量设为“${value === 'true' ? '有' : '无'}指标分配资格”?`)) return;
const data = await api(`/api/admin/indicator-qualifications/${toolbar.dataset.examId}/bulk`, { method: 'PUT', body: { userIds, eligible: value === 'true' } });
toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`);
return renderRoute();
}
if (action === 'bulk-placement-review') {
const ids = [...document.querySelectorAll('#placementReviewTable [data-placement-select]:checked')].map(input => input.value);
const decision = target.dataset.decision;
if (!ids.length) return toast('请先选择待审核考生', '可勾选单人,或选择当前筛选结果');
let note = '';
if (decision === 'withdraw') {
note = window.prompt(`将为所选 ${ids.length} 名考生申请退档,请填写统一的特殊理由(至少 8 个字)`, '') ?? '';
if (!note) return;
if (note.trim().length < 8) return toast('退档理由至少需要 8 个字');
} else if (!window.confirm(`确认批量接收所选 ${ids.length} 名投档考生吗?`)) return;
const result = await api('/api/admission/placements/bulk', { method: 'POST', body: { ids, decision, note } });
toast(decision === 'accept' ? '批量接收完成' : '批量退档申请已提交', `已处理 ${result.count} 名考生`);
return renderRoute();
}
if (action === 'toggle-admin-account') {
const active = target.dataset.active === 'true';
if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return;
await api(`/api/admin/admins/${target.dataset.id}`, { method: 'PATCH', body: { active } });
toast(active ? '管理员账户已启用' : '管理员账户已停用', active ? '该账号可以重新登录' : '现有会话已结束,历史记录仍保留');
return renderRoute();
}
if (action === 'reset-admin-password') {
if (!window.confirm('确认重置该管理员密码?其现有登录会话会立即失效。')) return;
const data = await api(`/api/admin/admins/${target.dataset.id}/reset-password`, { method: 'POST' });
setModal(`ADMIN PASSWORD RESET管理员临时密码已生成
请通过线下安全渠道交给管理员,本页面关闭后不再展示。
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}旧密码和现有登录会话均已失效。
`);
return;
}
if (action === 'toggle-admission-account') {
const active = target.dataset.active === 'true';
if (!active && !window.confirm('确认停用该招生学校账户?现有登录会话会立即失效,历史审核记录继续保留。')) return;
await api(`/api/admin/admission-school-accounts/${target.dataset.id}`, { method: 'PATCH', body: { active } });
toast(active ? '招生学校账户已启用' : '招生学校账户已停用');
return renderRoute();
}
if (action === 'reset-admission-account-password') {
if (!window.confirm('确认重置该招生学校账户密码?旧密码和现有登录会话会立即失效。')) return;
const data = await api(`/api/admin/admission-school-accounts/${target.dataset.id}/reset-password`, { method: 'POST' });
setModal(`ADMISSION ACCOUNT RESET招生学校临时密码已生成
请通过线下安全渠道交给招生学校,本页面关闭后不再展示。
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}账户已同步启用,旧密码和现有会话均已失效。
`);
return;
}
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 === 'edit-notice') { await openNoticeForm(state.pageData.notices.find(item => item.id === target.dataset.id)); 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 === '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' });
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 === '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');
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
if (target.dataset.examId) query.set('examId', target.dataset.examId);
else if (target.dataset.resource === 'results' && state.resultExamFilter) query.set('examId', state.resultExamFilter);
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;
state.resultSubjectFilter = '';
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
if (state.tablePages[key]) state.tablePages[key].page = 1;
});
return renderRoute();
}
if (action === 'table-page') {
const key = target.dataset.tableKey;
const current = state.tablePages[key] || { page: 1, pageSize: 50 };
state.tablePages[key] = { ...current, page: Math.max(1, Number(target.dataset.page) || 1) };
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 = `${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合${data.warnings.length ? `
${data.warnings.map(item => `- ${h(item)}
`).join('')}
` : '
多科目同考点、容量、科目时间和号码唯一性检查均已通过。'}
`;
} 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 === 'toggle-publication') {
const visible = target.dataset.visible === 'true';
await api(`/api/admin/publications/${target.dataset.sourceType}/${target.dataset.id}`, { method: 'PATCH', body: { visible } });
toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); 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('[data-result-score]')) updateResultScoreInput(event.target);
if (event.target.matches('[data-feature-score]')) updateFeatureScoreInput(event.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-action="table-page-size"]')) {
const key = event.target.dataset.tableKey;
state.tablePages[key] = { page: 1, pageSize: Number(event.target.value) || 50 };
renderRoute();
return;
}
if (event.target.matches('[data-placement-select]')) updatePlacementSelection();
if (event.target.matches('[data-placement-select-all]')) {
const table = document.getElementById(event.target.dataset.target);
table?.querySelectorAll('[data-placement-select]:not(:disabled)').forEach(input => {
if (!input.closest('tr').hidden) input.checked = event.target.checked;
});
updatePlacementSelection();
}
if (event.target.matches('[data-action="result-bulk-exam"]')) {
state.resultExamFilter = event.target.value;
state.resultSubjectFilter = '';
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
if (state.tablePages[key]) state.tablePages[key].page = 1;
});
renderRoute();
}
if (event.target.matches('[data-action="result-bulk-subject"]')) {
state.resultSubjectFilter = event.target.value;
if (state.tablePages.resultEntryTable) state.tablePages.resultEntryTable.page = 1;
renderRoute();
}
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 => {
if (!input.closest('tr').hidden) input.checked = event.target.checked;
});
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];
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 = `${classes.filter(item => item.schoolId === event.target.value).map(item => ``).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 = `${(category?.types || []).map(item => ``).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 = `${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => ``).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 = `${(exam?.subjects || []).map(subject => ``).join('')}`;
if (candidateSelect) { candidateSelect.innerHTML = ''; 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 = `${registrations.map(item => ``).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 = event.submitter || 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(`SECOND STEP输入动态验证码
打开验证器应用,输入当前显示的 6 位验证码;也可以使用一个恢复码。
`);
} 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(`CANDIDATE NUMBER请保存你的报名号
该号码就是长期使用的考生账户。
固定报名号${h(data.registrationNumber)}以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。
`);
} 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(`AUTHENTICATOR SETUP扫描二维码
在验证器应用中添加账户,然后输入应用显示的 6 位验证码完成绑定。
无法扫码?手动输入密钥${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}类型:基于时间 · 6 位 · 每 30 秒更新
`);
} 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(`PASSWORD RESET临时密码已生成
请通过线下安全渠道交给考生,仅此处展示一次。
报名号${h(data.candidateNumber)}临时密码${h(data.temporaryPassword)}原密码和现有登录会话均已失效,考生下次登录必须修改此密码。
`);
} 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;
const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices';
await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body });
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); 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 === 'result-bulk-entry') {
const mode = event.submitter?.dataset.resultMode || 'draft';
const inputs = [...form.querySelectorAll('[data-result-score]')];
const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max)));
if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); }
const targets = mode === 'publish'
? inputs.filter(input => input.value.trim() !== '')
: inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== '');
if (mode === 'publish') {
const missing = inputs.filter(input => input.value.trim() === '');
if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); }
if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
}
if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩');
const body = {
examId: form.examId.value,
subjectId: form.subjectId.value,
published: mode === 'publish',
rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) }))
};
const result = await api('/api/admin/results/bulk', { method: 'POST', body });
toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`);
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 === 'feature-score-bulk') {
const inputs = [...form.querySelectorAll('[data-feature-score]')];
const invalid = inputs.find(input => input.value.trim() === '' || !Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > 1000);
if (invalid) { invalid.focus(); throw new Error('特征分必须在 0—1000 之间;未参加时填写 0'); }
const targets = inputs.filter(input => input.dataset.dirty === 'true');
if (!targets.length) throw new Error('请先修改至少一名考生的特征分');
const result = await api('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: form.examId.value, rows: targets.map(input => ({ registrationId: input.dataset.registrationId, featureScore: Number(input.value) })) } });
toast('特征分已保存', `${result.count} 名考生已更新;其余考生继续保持 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(`ADMIN SCOPE添加分级管理员
同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。
`);
}
function openSchoolForm(school = null) {
setModal(`SCHOOL DIRECTORY${school ? '编辑学校档案' : '创建学校'}
学校代码统一标识学校;同一学校可以同时承担生源校和招生校职责。
`);
}
function openSchoolClassForm(schoolClass = null) {
setModal(`SCHOOL CLASS${schoolClass ? '编辑本校班级' : '新增本校班级'}
班级是考生资料、报名和成绩权限的最小范围。
`);
}
function openClassAdminForm(admin = null, classId = '') {
const classes = state.pageData.classes || [];
setModal(`CLASS SCOPE${admin ? '维护班级管理员' : '添加班级管理员'}
该账号可查看并审批所绑定班级的考生、报名与成绩复议流程。
`);
}
function centerRoomEditor(room = {}) {
return ``;
}
function openCenterForm(center = null) {
const schools = state.pageData.schools || [];
const rooms = center?.rooms?.length ? center.rooms : [{}];
setModal(`CONTROLLED DOSSIER${center ? '提交考点档案变更' : '提交新考点档案'}
表单将进入“考点考场变更审批”,通过前不会改动正式数据。
`);
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 => `${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}
`).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 ? `- 地址
- ${h(change.address)}
- 负责人
- ${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
- 开放时间
- ${h(change.gateOpenTime || '未填写')}
- 档案状态
- ${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}`).join('')}
` : '';
const batch = instance.accountBatch;
const batchSnapshot = batch ? `${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}
最终批准时才生成固定报名号和随机初始密码。
` : '';
const appeal = instance.appealResult;
const appealSnapshot = appeal ? `- 当前等级
- ${h(appeal.grade)}
- 本科排名
- 第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%)
- 单科规则
- ${h(appeal.passText)}
- 当前结论
- ${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}
` : '';
const reviewedScoreField = finalAppealStep ? `` : '';
setModal(`WORKFLOW TRACE${h(instance.workflowName)}
${h(subject)} · ${h(subjectDetail)}
${changeSnapshot}${batchSnapshot}${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
${canProcess ? `` : '当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`);
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 || state.user.adminLevel === 'super');
const registrations = item.registrations || [];
const examCards = registrations.map(registration => `- 考试时间
- ${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}
- 考试地点
- ${h(registration.exam?.location || '待公布')}
- 报名科目
${registration.subjects?.map(subject => `${h(subject.name)}${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}`).join('') || '未选择科目'}
- 报名 / 缴费
- ${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}
`).join('');
const decision = canReview ? `` : ``;
setReviewSubpage(`- 证件号码
- ${h(item.idNumberMasked)}
- 性别 / 出生日期
- ${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}
- 籍贯 / 民族
- ${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}
- 就读学校 / 班级
- ${h(item.school || '未填写')} · ${h(item.grade || '未填写')}
- 联系电话
- ${h(item.phone || '未填写')}
- 电子邮箱
- ${h(item.email || '未填写')}
- 家庭住址
- ${h(item.address || '未填写')}
- 监护人
- ${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}
- 紧急联系人
- ${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}
02关联考试与报名科目
审核资料时同时查看该考生历次报名上下文。
${registrations.length} 场${examCards || '
暂无考试报名该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。
'}
${decision}
`);
}
function openCandidatePasswordReset(id) {
const item = state.pageData?.candidates?.find(candidate => candidate.id === id);
if (!item) return toast('考生不存在', '请刷新页面后重试');
setModal(`RESET PASSWORD重置 ${h(item.name)} 的密码
报名号 ${h(item.candidateNumber)}
`);
}
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');
const subjects = reg.subjects || [];
const subjectCards = subjects.map((subject, index) => `${index + 1}${h(subject.name)}${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `—${h(subject.end)}` : ''}
- 满分
- ${h(subject.fullScore)} 分
- 报名费
- ${money(subject.fee || 0)}
`).join('');
const decision = canReview ? `` : ``;
setReviewSubpage(`考试${h(reg.exam.code)}
${h(reg.exam.name)}
- 报名时间
- ${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}
- 考试时间
- ${dateRange(reg.exam.examStart, reg.exam.examEnd)}
- 考试地点
- ${h(reg.exam.location || '待公布')}
- 本次报名
- ${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}
- 缴费状态
- ${badge(reg.paymentStatus)}
- 账户报名号
- ${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}
- 姓名 / 性别
- ${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}
- 证件号码
- ${h(reg.candidate?.idNumber || '未填写')}
- 学校 / 班级
- ${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}
- 联系电话
- ${h(reg.candidate?.phone || '未填写')}
${decision}
`);
}
async function openNoticeForm(notice = null) {
if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试');
const editing = Boolean(notice);
const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告'];
if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category);
setModal(`${editing ? 'EDIT DRAFT' : 'NEW NOTICE'}${editing ? '编辑通知草稿' : '发布通知公告'}
${editing ? '修改后可继续保存草稿,也可直接发布。' : '发布后将立即显示在公开首页和考生中心。'}
`);
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 ``;
}
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(`${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'}${editing ? '编辑考试草稿' : '创建考试与科目'}
先定义科目计分,再选择整场考试的合格判定方式。
`);
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 `| ${h(subject.name)} | ${h(subject.date)} ${h(subject.start)} | ${h(assignment.examRoomCode || '待定')} | ${h(assignment.roomName || assignment.room || '待定')}场地代码 ${h(assignment.roomCode || '—')} | ${h(assignment.building || '楼栋待定')} · ${h(assignment.floor || '楼层待定')} | ${h(assignment.seat || '—')} |
`;
}).join('');
setModal(`ADMISSION CARD准考证编排明细
${h(reg.candidate?.name)} · ${h(reg.exam.name)}
${h(reg.admitCard.number)}- 固定考点
- ${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
- 生成时间
- ${formatDate(reg.admitCard.generatedAt,true)}
| 科目 | 时间 | 考试考场序号 | 考场通用名称 / 场地代码 | 楼栋 / 楼层 | 座位 |
${rows}
“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。
`);
}
window.addEventListener('hashchange', renderRoute);
window.addEventListener('keydown', event => {
if (event.key === 'Escape') closeModal();
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
const form = document.querySelector('form[data-form="result-bulk-entry"]');
if (form) {
event.preventDefault();
form.requestSubmit(form.querySelector('[data-result-mode="draft"]'));
}
}
});
try {
await Promise.all([refreshPublic(), refreshSession()]);
await renderRoute();
} catch (error) {
renderError(error);
}