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