Files
Exam-Information-System/app.js
T

763 lines
74 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 { 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';
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, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
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 === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page);
else navigate('home');
}
function formObject(form) {
return Object.fromEntries(new FormData(form).entries());
}
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 || '';
}
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 === '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 (['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-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 === '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 === '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 === '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));
document.querySelectorAll(`#${target.dataset.target} tbody tr`).forEach(row => row.hidden = target.dataset.status !== 'all' && !String(row.dataset.status || '').split(/\s+/).includes(target.dataset.status));
}
} catch (error) { toast('操作未完成', error.message); }
});
document.addEventListener('input', event => {
if (event.target.matches('[data-action="table-search"]')) {
const query = event.target.value.trim().toLowerCase();
document.querySelectorAll(`#${event.target.dataset.target} tbody tr`).forEach(row => row.hidden = !row.textContent.toLowerCase().includes(query));
}
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-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-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) });
state.user = data.user; await refreshSession();
toast('登录成功', `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
} 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 === '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 === '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 === '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 === '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 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;
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);
}