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;
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 = `
`;
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
}
function closeModal() {
modalRoot.innerHTML = '';
}
function emptyState(title, description, route, action) {
return `${icons.ticket}${h(title)}
${h(description)}
${route ? `` : ''}`;
}
function renderError(error) {
app.innerHTML = `!页面暂时无法加载
${h(error.message)}
`;
}
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 === 'fill-demo') {
const form = document.querySelector('[data-form="login"]');
const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['2026-HZ01-F-0001', 'Candidate123!'] };
[form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate;
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;
setModal(`${h(notice.category)}${h(notice.title)}
${formatDate(notice.publishAt,true)} · ${h(notice.author)}
${h(notice.content).replace(/\n/g, '
')}
`); 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') return openNoticeForm();
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 = `${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 === '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 = `${classes.filter(item => item.schoolId === event.target.value).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 = 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(`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 === '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 === '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); 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(`ADMIN SCOPE添加分级管理员
同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。
`);
}
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;
setModal(`CANDIDATE REVIEW${canReview ? '处理' : '查看'} ${h(item.name)} 的资料
报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}
- 证件号码
- ${h(item.idNumberMasked)}
- 性别 / 籍贯
- ${h(item.gender)} · ${h(item.nativePlace)}
- 联系电话
- ${h(item.phone)}
- 电子邮箱
- ${h(item.email)}
- 就读学校
- ${h(item.school)}
- 年级班级
- ${h(item.grade)}
- 家庭住址
- ${h(item.address)}
- 监护人
- ${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}
- 当前步骤
- ${h(item.workflow?.currentStepDetail?.name || '流程已结束')}
- 当前责任人
- ${h(item.workflow?.assignee?.displayName || '—')}
${canReview ? `` : ''}`);
}
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;
setModal(`REGISTRATION REVIEW${canReview ? '处理' : '查看'}考试报名
${h(reg.candidate?.name)} · ${h(reg.exam.name)}
报考科目${reg.subjects.map(subject => `${h(subject.name)}`).join('')}
- 账户报名号
- ${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}
- 当前步骤
- ${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
- 责任人
- ${h(reg.workflow?.assignee?.displayName || '—')}
- 缴费状态
- ${badge(reg.paymentStatus)}
${canReview ? `` : ''}`);
}
function openNoticeForm() {
setModal(`NEW NOTICE发布通知公告
发布后将立即显示在公开首页和考生中心。
`);
}
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(); });
try {
await Promise.all([refreshPublic(), refreshSession()]);
await renderRoute();
} catch (error) {
renderError(error);
}