import { api } from './src/client/api.mjs'; import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs'; import { createCandidateViews } from './src/client/candidate-views.mjs'; import { createAdmissionViews } from './src/client/admission-views.mjs'; import { 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 = ``; 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 `
${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 }); const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand }); function navigate(route) { location.hash = route; if (location.hash.slice(1) === route) renderRoute(); } async function renderRoute() { closeModal(); window.scrollTo({ top: 0, behavior: 'instant' }); const route = location.hash.slice(1) || 'home'; const [section, page = 'dashboard'] = route.split('/'); if (section === 'home') renderHome(); else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); else if (section === 'admission_school') await renderAdmission(page); else navigate('home'); } function formObject(form) { return Object.fromEntries(new FormData(form).entries()); } function updateRegistrationSelection() { const table = document.querySelector('#registrationTable'); if (!table) return; const selectable = [...table.querySelectorAll('[data-registration-select]:not(:disabled)')]; const visible = selectable.filter(input => !input.closest('tr').hidden); const selected = selectable.filter(input => input.checked); const selectAll = table.querySelector('[data-registration-select-all]'); if (selectAll) { selectAll.checked = visible.length > 0 && visible.every(input => input.checked); selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; } const count = document.querySelector('[data-registration-selection-count]'); if (count) count.textContent = selected.length; document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; }); } function applyTableFilters(tableId) { const table = document.getElementById(tableId); if (!table) return; const search = [...document.querySelectorAll('[data-action="table-search"]')].find(input => input.dataset.target === tableId); const query = search?.value.trim().toLowerCase() || ''; const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all'; const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value); table.querySelectorAll('tbody tr[data-filter-row], tbody tr[data-status]').forEach(row => { const matchesSearch = !query || row.textContent.toLowerCase().includes(query); const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status); const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '') === select.value); row.hidden = !(matchesSearch && matchesStatus && matchesFilters); }); if (tableId === 'registrationTable') updateRegistrationSelection(); } async function refreshPublic() { state.publicData = await api('/api/public/home'); } async function refreshSession() { const session = await api('/api/auth/me'); state.user = session.user; state.profile = session.profile; state.permissions = session.permissions || []; state.scopeLabel = session.scopeLabel || ''; } async function finishLogin(data) { state.user = data.user; await refreshSession(); closeModal(); toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`); } function showRecoveryCodes(codes) { setModal(`
${codes.map(code => `${h(code)}`).join('')}
请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。
`); } document.addEventListener('click', async event => { const routeTarget = event.target.closest('[data-route]'); if (routeTarget) { event.preventDefault(); return navigate(routeTarget.dataset.route); } if (event.target.matches('[data-modal-backdrop]')) return closeModal(); const target = event.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; try { if (action === 'close-modal') return closeModal(); if (action === 'close-modal-refresh') { closeModal(); return renderRoute(); } if (action === 'copy-recovery-codes') { const codes = [...document.querySelectorAll('[data-recovery-codes] code')].map(item => item.textContent).join('\n'); await navigator.clipboard.writeText(codes); return toast('恢复码已复制', '请保存到可信的离线位置'); } if (action === 'copy-totp-secret') { const secret = document.querySelector('[data-totp-secret]')?.textContent.replace(/\s/g, '') || ''; await navigator.clipboard.writeText(secret); return toast('手动密钥已复制'); } if (action === 'retry') return renderRoute(); if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open'); if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open'); if (action === 'toggle-public-nav') return document.querySelector('.public-header nav')?.classList.toggle('open'); if (action === 'scroll-to') { event.preventDefault(); if (!document.querySelector(`#${target.dataset.target}`)) { navigate('home'); setTimeout(() => document.querySelector(`#${target.dataset.target}`)?.scrollIntoView({ behavior: 'smooth' }), 80); } else document.querySelector(`#${target.dataset.target}`).scrollIntoView({ behavior: 'smooth' }); return; } if (action === 'logout') { await api('/api/auth/logout', { method: 'POST' }); state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; 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 || `

${h(notice.content).replace(/\r?\n/g, '

')}

`; setModal(`
${contentHtml}
`); return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'admission-plan-review') { const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null; if (reviewNote == null) return; await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } }); toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute(); } if (action === 'admission-match') { if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return; const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' }); toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute(); } if (action === 'admission-finalize') { if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return; const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' }); await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute(); } if (action === 'admission-supplementary') { const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00)', ''); if (!preferenceEnd) return; await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } }); toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute(); } if (action === 'withdrawal-review') { const approved = target.dataset.approved === 'true'; const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', ''); if (reviewNote == null) return; await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } }); toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute(); } if (['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 = `
${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合${data.warnings.length ? `` : '多科目同考点、容量、科目时间和号码唯一性检查均已通过。'}
`; } 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 = `${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) }); if (data.requiresTotp) { setModal(``); } 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(`
固定报名号${h(data.registrationNumber)}

以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。

`); } else if (kind === 'candidate-password') { const body = formObject(form); if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); await api('/api/auth/change-password', { method: 'POST', body }); await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding'); } else if (kind === 'account-password') { const body = formObject(form); if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); await api('/api/auth/change-password', { method: 'POST', body }); form.reset(); toast('密码修改成功', '下次登录请使用新密码'); } else if (kind === 'totp-setup') { const data = await api('/api/auth/totp/setup', { method: 'POST', body: formObject(form) }); setModal(`
TOTP 绑定二维码
无法扫码?手动输入密钥${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}类型:基于时间 · 6 位 · 每 30 秒更新
`); } else if (kind === 'totp-enable') { const data = await api('/api/auth/totp/enable', { method: 'POST', body: formObject(form) }); state.user = data.user; showRecoveryCodes(data.recoveryCodes); } else if (kind === 'totp-recovery-codes') { const data = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: formObject(form) }); showRecoveryCodes(data.recoveryCodes); } else if (kind === 'totp-disable') { await api('/api/auth/totp/disable', { method: 'POST', body: formObject(form) }); await refreshSession(); toast('二次验证已关闭', '账户现在仅使用密码登录'); renderRoute(); } else if (kind === 'candidate-password-reset') { const body = formObject(form); const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' }); setModal(`
报名号${h(data.candidateNumber)}临时密码${h(data.temporaryPassword)}

原密码和现有登录会话均已失效,考生下次登录必须修改此密码。

`); } else if (kind === 'candidate-profile') { const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); } else if (kind === 'volunteer-preference') { const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => { const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode }; }); await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute(); } else if (kind === 'exam-registration') { const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); await api('/api/candidate/registrations', { method: 'POST', body }); toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute(); } else if (kind === 'score-appeal') { const body = formObject(form); await api(`/api/candidate/results/${body.resultId}/appeals`, { method: 'POST', body: { reason: body.reason } }); toast('成绩复议已提交', '系统已按班级、学校和考试中心流程自动分配'); renderRoute(); } else if (kind === 'candidate-review') { const body = formObject(form); await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body }); closeModal(); toast(body.status === 'approved' ? '资料审核通过' : '资料已退回', '考生端状态已同步'); renderRoute(); } else if (kind === 'registration-review') { const body = formObject(form); await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body }); closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute(); } else if (kind === 'admin-form') { const body = formObject(form); await api('/api/admin/admins', { method: 'POST', body }); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); } else if (kind === 'admission-setting') { const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body }); toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute(); } else if (kind === 'admission-account') { await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) }); form.reset(); toast('招生学校账号已创建'); renderRoute(); } else if (kind === 'admission-plan' || kind === 'school-admission-plan') { const body = formObject(form); body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => { const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim()); const indicatorAllocations = indicators.split(/[,,]/).map(entry => { const [sourceSchoolId, count] = entry.split(':').map(item => item.trim()); return { sourceSchoolId, quota: Number(count) }; }).filter(item => item.sourceSchoolId && item.quota > 0); return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations }; }).filter(item => item.name && item.quota > 0); if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划'); await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body }); toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute(); } else if (kind === 'placement-review') { const body = formObject(form); await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body }); toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute(); } else if (kind === 'school-form') { const body = formObject(form); body.active = form.active.checked; 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(``); } function openSchoolForm(school = null) { setModal(``); } function openSchoolClassForm(schoolClass = null) { setModal(``); } function openClassAdminForm(admin = null, classId = '') { const classes = state.pageData.classes || []; setModal(``); } function centerRoomEditor(room = {}) { return `
结构化考场
`; } function openCenterForm(center = null) { const schools = state.pageData.schools || []; const rooms = center?.rooms?.length ? center.rooms : [{}]; setModal(``); 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.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${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.totalCount} 个待建账户

${batch.quotas.length} 个班级
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}

最终批准时才生成固定报名号和随机初始密码。

` : ''; const appeal = instance.appealResult; const appealSnapshot = appeal ? `
复议成绩快照

${h(appeal.examName)} · ${h(appeal.subjectName)}

${h(appeal.score)} / ${h(appeal.fullScore)}
当前等级
${h(appeal.grade)}
本科排名
第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%)
单科规则
${h(appeal.passText)}
当前结论
${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}
` : ''; const reviewedScoreField = finalAppealStep ? `` : ''; setModal(`${changeSnapshot}${batchSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${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(`
证件号码
${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(``); } 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(`
报考科目

${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 ? `` : ''}`); } async function openNoticeForm() { setModal(``); const source = modalRoot.querySelector('[data-notice-editor]'); try { const [ckeditor, translation] = await loadCKEditor(); const { ClassicEditor, AutoImage, AutoLink, BlockQuote, Bold, Essentials, Heading, Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, Italic, Link, List, Paragraph, Table, TableCaption, TableToolbar, Underline } = ckeditor; if (!source?.isConnected) return; noticeEditor = await ClassicEditor.create(source, { licenseKey: 'GPL', language: 'zh-cn', translations: [translation.default], plugins: [ Essentials, Paragraph, Heading, Bold, Italic, Underline, Link, AutoLink, List, BlockQuote, Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, AutoImage, Table, TableToolbar, TableCaption ], toolbar: { items: ['heading', '|', 'bold', 'italic', 'underline', '|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'link', 'insertImageViaUrl', 'insertTable', '|', 'undo', 'redo'], shouldNotGroupWhenFull: true }, heading: { options: [ { model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' }, { model: 'heading2', view: 'h2', title: '二级标题', class: 'ck-heading_heading2' }, { model: 'heading3', view: 'h3', title: '三级标题', class: 'ck-heading_heading3' } ] }, link: { defaultProtocol: 'https://', addTargetToExternalLinks: true }, image: { toolbar: ['imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative'] }, table: { contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'toggleTableCaption'] }, placeholder: '请输入完整通知内容;图片使用 URL 插入,文件使用超链接' }); } catch (error) { console.error('CKEditor failed to load', error); toast('富文本编辑器加载失败', '已保留普通文本输入,可检查网络后重试'); } } function dateTimeLocal(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const pad = number => String(number).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function examSubjectEditor(subject = {}) { const passRule = subject.passRule === 'score_ratio' ? 'rank_percent' : (subject.passRule || 'fixed_score'); const passValue = subject.passValue ?? subject.passScore ?? 60; return `
科目明细

`; } function refreshSubjectPassRuleRow(row) { if (!row) return; const ruleSelect = row.querySelector('[name="subjectPassRule"]'); const legacyOption = ruleSelect?.querySelector('option[value="score_ratio"]'); if (legacyOption) { legacyOption.value = 'rank_percent'; legacyOption.textContent = '按排名比例'; } const rule = ruleSelect?.value || 'fixed_score'; const fullScore = Number(row.querySelector('[name="subjectFullScore"]')?.value || 0); const valueField = row.querySelector('[data-subject-pass-value]'); const input = row.querySelector('[name="subjectPassValue"]'); const label = row.querySelector('[data-subject-pass-label]'); const unit = row.querySelector('[data-subject-pass-unit]'); const preview = row.querySelector('[data-subject-pass-preview]'); valueField?.classList.toggle('hidden', rule === 'none'); if (input) { input.disabled = rule === 'none'; input.max = rule === 'rank_percent' ? '100' : String(fullScore || 1000); input.min = rule === 'rank_percent' ? '0.1' : '0'; } if (label) label.textContent = rule === 'rank_percent' ? '排名比例 *' : '固定及格分 *'; if (unit) unit.textContent = rule === 'rank_percent' ? '%' : '分'; const value = Number(input?.value || 0); if (preview) preview.textContent = rule === 'none' ? '本科只展示成绩,不单独判定达线。' : rule === 'rank_percent' ? `本科排名前 ${value}% 达线;分数边界随本次已发布成绩队列变化。` : `本科达到 ${value} 分视为单科达线。`; } function refreshExamScoringForm(form) { if (!form?.matches('[data-form="exam-form"]')) return; const rows = [...form.querySelectorAll('.exam-subject-editor')]; rows.forEach(refreshSubjectPassRuleRow); const total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0); const totalElement = form.querySelector('[data-exam-total]'); const countElement = form.querySelector('[data-exam-subject-count]'); if (totalElement) totalElement.textContent = total; if (countElement) countElement.textContent = rows.length; const policySelect = form.querySelector('[name="passPolicy"]'); policySelect?.querySelector('option[value="score_ratio"]')?.remove(); const policy = policySelect?.value || 'rank_percent'; const valueField = form.querySelector('[data-pass-value-field]'); const valueInput = form.querySelector('[name="passValue"]'); const unit = form.querySelector('[data-pass-unit]'); const hint = form.querySelector('[data-pass-hint]'); const hiddenValue = ['subject_scores', 'none'].includes(policy); if (valueField) valueField.classList.toggle('hidden', hiddenValue); if (valueInput) { valueInput.disabled = hiddenValue; valueInput.max = policy === 'fixed_score' ? String(total || 1000) : '100'; valueInput.min = policy === 'fixed_score' ? '0' : '0.1'; } if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%'; const hints = { fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。', rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。', subject_scores: '每个科目都必须达到上方配置的单科合格分。', none: '只展示成绩、总分和排名,不显示合格或未合格。' }; if (hint) hint.textContent = hints[policy]; } function openExamForm(exam = null) { const editing = Boolean(exam); if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿'); const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }]; const passPolicy = exam?.passPolicy === 'score_ratio' ? 'rank_percent' : (exam?.passPolicy || 'rank_percent'); setModal(``); 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(`
${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); }