const state = { user: null, profile: null, publicData: { organization: {}, notices: [], exams: [], stats: {} }, permissions: [], scopeLabel: '', pageData: null, loading: false }; const app = document.querySelector('#app'); const modalRoot = document.querySelector('#modalRoot'); const statusLabels = { pending: '待审核', approved: '已通过', rejected: '需修改', published: '已发布', draft: '草稿', closed: '已结束', open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', super: '超级管理员', school: '校级管理员', class: '班级管理员' }; const icons = { home: '', user: '', exam: '', ticket: '', chart: '', bell: '', users: '', check: '', plus: '', logout: '', menu: '', search: '', arrow: '' }; function h(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function formatDate(value, withTime = false) { if (!value) return '待定'; const date = new Date(value); if (Number.isNaN(date.getTime())) return h(value); return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); } function dateRange(start, end) { return `${formatDate(start)} — ${formatDate(end)}`; } function badge(status) { return `${h(statusLabels[status] || status)}`; } function money(value) { return `¥${Number(value || 0).toFixed(2)}`; } async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); const type = response.headers.get('content-type') || ''; const data = type.includes('application/json') ? await response.json() : await response.text(); if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试'); return data; } let toastTimer; function toast(title, message = '') { const element = document.querySelector('#toast'); element.querySelector('strong').textContent = title; element.querySelector('small').textContent = message; element.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => element.classList.remove('show'), 2800); } function setModal(content) { modalRoot.innerHTML = ``; setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30); } function closeModal() { modalRoot.innerHTML = ''; } function brand() { return `衡准EXAM SERVICE`; } function publicHeader() { return `
${brand()}
`; } function renderHome() { const { notices, exams, stats, organization } = state.publicData; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const topNotice = notices[0]; app.innerHTML = `${publicHeader()}
最新

HAIZHOU EXAMINATION SERVICE

每一次应考,
都有清晰的下一步。

从身份核验、选科报名到准考证与成绩查询,所有考试事项集中办理,进度实时可见。

${state.user?.role === 'candidate' ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}

SERVICE FLOW

从注册到查分,只需五步

每一步都会在考生中心显示当前状态和下一项操作。

${[['01','注册并填写资料','使用手机号注册,完善实名信息。'],['02','等待资料审核','管理员核验身份与学籍信息。'],['03','选择考试科目','在报名期内自主勾选报考科目。'],['04','下载准考证','管理员生成后,在开放期内下载。'],['05','查询考试成绩','成绩发布后登录平台查询。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
`; } function renderHeroTicket(exam) { const status = exam.registrationState; return `
${badge(status)}${h(exam.code)}

UPCOMING EXAM

${h(exam.name)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考试地点
${h(exam.location)}
${exam.subjects.slice(0, 5).map(subject => `${h(subject.name)}`).join('')}${exam.subjects.length > 5 ? `+${exam.subjects.length - 5}` : ''}
报名人数${h(exam.registrationCount || 0)}
`; } function renderNoticeRow(notice) { return ``; } function renderPublicExam(exam) { return `
${h(exam.code)}${badge(exam.registrationState)}

${h(exam.name)}

${h(exam.description)}

报名${dateRange(exam.registrationStart, exam.registrationEnd)}考试${dateRange(exam.examStart, exam.examEnd)}
`; } function renderAuth(kind) { const login = kind === 'login'; app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '欢迎回来,' : '从这里,'}
${login ? '继续你的考试进程。' : '开始你的应考旅程。'}

统一管理报名、审核、准考证与成绩,不错过每一个关键节点。

考试服务承诺

资料有状态、报名有回执、下载有时间、成绩有依据。

${login ? 'ACCOUNT LOGIN' : 'CREATE ACCOUNT'}

${login ? '登录衡准' : '考生自主注册'}

${login ? '使用考生账号或管理员账号进入系统。' : '请填写真实身份信息,注册后由管理员审核。'}

${login ? loginForm() : registerForm()}
${login ? '还没有考生账号?' : '已经注册过?'}
${login ? `
演示账号
` : ''}
`; } function loginForm() { return `
`; } function registerForm() { const schools = state.publicData.schools || []; return `
`; } const candidateNav = [ ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'] ]; function adminNavForUser() { const level = state.user?.adminLevel || 'super'; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; if (level === 'class') return core; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; if (level === 'school') return [core[0], core[1], core[2], ...operations, core[3]]; return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]]; } function portalShell(role, page, content, title, description) { const nav = role === 'admin' ? adminNavForUser() : candidateNav; const roleName = role === 'admin' ? '管理后台' : '考生中心'; const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; return `
${roleName}/${h(title)}
${role === 'admin' && state.user?.adminLevel !== 'class' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; } function portalHeadingAction(role, page) { if (role === 'admin' && page === 'notices') return ``; if (role === 'admin' && page === 'exams') return ``; if (role === 'admin' && page === 'admins') return ``; if (role === 'admin' && page === 'centers') return ``; if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; return ''; } function loadingPanel() { return `
正在读取数据
`; } async function renderCandidate(page) { if (state.user?.role !== 'candidate') return navigate('login'); const meta = { dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'], registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'] }; if (!meta[page]) page = 'dashboard'; app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); try { const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations'; const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`); state.pageData = data; if (data.profile) state.profile = data.profile; const content = { dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices) }[page](); app.innerHTML = portalShell('candidate', page, content, ...meta[page]); } catch (error) { renderError(error); } } function candidateDashboard(data) { const registration = data.registrations[0]; const steps = [ ['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'], ['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'], ['考试报名', Boolean(registration), registration ? '已报名' : '未报名'], ['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'], ['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布'] ]; return `
${new Date().getHours() < 12 ? '上午好' : '下午好'}

${h(data.profile?.name || state.user.displayName)},下一步已为你标出。

${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}


${icons.user}
个人资料${statusLabels[data.profile?.status] || '未填写'}
${badge(data.profile?.status || 'pending')}
${icons.exam}
已报名考试${data.registrations.length} 场
${icons.ticket}
可下载准考证${data.registrations.filter(item => item.admitCard).length} 份
${icons.chart}
已发布成绩${data.results.length} 科

我的应考进度

自动更新
${steps.map((step, index) => `
${step[1] ? '✓' : index + 1}
${step[0]}${step[2]}
`).join('')}

最近通知

${data.notices.map(notice => ``).join('')}
`; } function candidateProfile(data) { const { profile, schools = [], classes = [], workflow } = data; const step = workflow?.currentStepDetail; return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

实名信息

姓名与证件号码须与有效证件完全一致。

02

学籍与联系信息

学校和班级决定资料的管理范围。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

保存后资料将按当前流程重新审批。

`; } function candidateExams(data) { return `
${data.exams.map(exam => `
${h(exam.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${h(exam.name)}

${h(exam.description)}

报名期限
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点安排
${h(exam.location)}
选择报考科目可多选
${exam.subjects.map(subject => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0合计 ¥0.00
`}
`).join('')}
`; } function candidateRegistrations(registrations) { return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
报名号
${h(reg.registrationNumber || '审批通过后生成')}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); } function candidateAdmit(registrations) { const cards = registrations.filter(reg => reg.admitCard); return cards.length ? `
${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场 / 座位
${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态'); } function candidateResults(results) { if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知'); const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {}); return `
${Object.entries(grouped).map(([examName, items]) => `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
${items.map(item => `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 150
`).join('')}
`).join('')}
`; } function candidateNotices(notices) { return `
${notices.map(notice => ``).join('')}
`; } async function renderAdmin(page) { if (state.user?.role !== 'admin') return navigate('login'); const meta = { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'], results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], 'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'], 'number-rules': ['报名号规则', '设计号码组成,并为缺失报名号的已审核记录批量生成。'] }; const allowedPages = adminNavForUser().map(item => item[0]); if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); try { const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page; const data = await api(`/api/admin/${endpoint}`); state.pageData = data; const content = { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); } catch (error) { renderError(error); } } function adminDashboard(data) { const m = data.metrics; const canFlow = state.user.adminLevel !== 'class'; return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; } function adminCandidates(candidates) { const readOnly = state.user.adminLevel === 'class'; return `
${candidates.map(item => ``).join('')}
考生证件号码学校 / 班级当前流程更新时间状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.gender || '未填写')} · ${h(item.phone)}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${h(item.workflow?.currentStepDetail?.name || '流程已结束')}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${badge(item.status)}
`; } function adminRegistrations(registrations) { const readOnly = state.user.adminLevel === 'class'; return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '审批通过后生成')}${formatDate(reg.createdAt,true)}${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; } function adminExams(exams) { return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('') || '科目待配置'}
`).join('')}
`; } function adminNotices(notices) { return `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; } function adminAdmit(registrations) { const approved = registrations.filter(reg => reg.status === 'approved'); return `

仅已通过报名审核的考生可生成准考证。

${approved.map(reg => ``).join('') || ''}
考生考试报考科目准考证号考点 / 考场下载时间操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.phone)}
${h(reg.exam.name)}${h(reg.exam.code)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.admitCard?.number || '尚未生成')}${reg.admitCard ? `${h(reg.admitCard.testCenter)}${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号` : '—'}${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
暂无已通过的考试报名
`; } function adminResults(data) { const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

保存后可立即发布
` : ''; return `
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; } function adminUsers(data) { return `

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; } function adminCenters(data) { const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位号状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatStart)}—${h(room.seatEnd)}${badge(room.status === 'active' ? 'approved' : 'closed')}
`).join(''); const requests = data.changeRequests || []; return `
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; } const numberSegmentMeta = { year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] }; function adminNumberRules(data) { const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); const types = Object.keys(numberSegmentMeta); const candidates = data.batchCandidates || []; return `

组合报名号

勾选字段并填写顺序;流水号为必选字段。

实时规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
BATCH GENERATION

批量生成报名号

只处理“审核通过且报名号为空”的记录,可按考试和学校缩小范围;批次一次提交、整体写入。

当前可生成${candidates.length}
${candidates.slice(0, 8).map(item => `${h(item.candidateName)}${h(item.schoolName)} · ${h(item.examName)}`).join('') || '

当前没有缺失报名号的已审核记录。

'}${candidates.length > 8 ? `另有 ${candidates.length - 8} 条` : ''}
`; } function adminFlowDesign(workflows) { const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE' }; return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; } function workflowStepEditor(step = {}) { return `
`; } function adminFlows(data) { const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更' }; return `
${data.instances.map(instance => { const title = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; const sub = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}
`; } function emptyState(title, description, route, action) { return `
${icons.ticket}

${h(title)}

${h(description)}

${route ? `` : ''}
`; } function renderError(error) { app.innerHTML = `
!

页面暂时无法加载

${h(error.message)}

`; } function navigate(route) { location.hash = route; if (location.hash.slice(1) === route) renderRoute(); } async function renderRoute() { closeModal(); window.scrollTo({ top: 0, behavior: 'instant' }); const route = location.hash.slice(1) || 'home'; const [section, page = 'dashboard'] = route.split('/'); if (section === 'home') renderHome(); else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); else navigate('home'); } function formObject(form) { return Object.fromEntries(new FormData(form).entries()); } async function refreshPublic() { state.publicData = await api('/api/public/home'); } async function refreshSession() { const session = await api('/api/auth/me'); state.user = session.user; state.profile = session.profile; state.permissions = session.permissions || []; state.scopeLabel = session.scopeLabel || ''; } document.addEventListener('click', async event => { const routeTarget = event.target.closest('[data-route]'); if (routeTarget) { event.preventDefault(); return navigate(routeTarget.dataset.route); } if (event.target.matches('[data-modal-backdrop]')) return closeModal(); const target = event.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; try { if (action === 'close-modal') return closeModal(); if (action === 'retry') return renderRoute(); if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open'); if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open'); if (action === 'toggle-public-nav') return document.querySelector('.public-header nav')?.classList.toggle('open'); if (action === 'scroll-to') { event.preventDefault(); if (!document.querySelector(`#${target.dataset.target}`)) { navigate('home'); setTimeout(() => document.querySelector(`#${target.dataset.target}`)?.scrollIntoView({ behavior: 'smooth' }), 80); } else document.querySelector(`#${target.dataset.target}`).scrollIntoView({ behavior: 'smooth' }); return; } if (action === 'logout') { await api('/api/auth/logout', { method: 'POST' }); state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; } if (action === 'fill-demo') { const form = document.querySelector('[data-form="login"]'); const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['13800138000', 'Candidate123!'] }; [form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate; return; } if (action === 'open-notice') { const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice; setModal(`

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

')}

`); return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); if (action === 'new-admin') return openAdminForm(); 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 === '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 === 'review-registration') return openRegistrationReview(target.dataset.id); if (action === 'generate-registration-number') { await api(`/api/admin/registrations/${target.dataset.id}/registration-number`, { method: 'POST' }); toast('报名号已生成', '已按当前启用规则写入'); return renderRoute(); } if (action === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); if (registration.admitCard) return openAdmitPreview(registration); const testCenter = prompt('请输入考点名称:', '海州市第一中学'); if (!testCenter) return; await api(`/api/admin/registrations/${target.dataset.id}/admit-card`, { method: 'POST', body: { testCenter } }); toast('准考证已生成', '考生可在开放时间内下载'); return renderRoute(); } 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 === 'toggle-notice') { await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute); } if (action === 'status-filter') { target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target)); document.querySelectorAll(`#${target.dataset.target} tbody tr`).forEach(row => row.hidden = target.dataset.status !== 'all' && row.dataset.status !== target.dataset.status); } } catch (error) { toast('操作未完成', error.message); } }); document.addEventListener('input', event => { if (event.target.matches('[data-action="table-search"]')) { const query = event.target.value.trim().toLowerCase(); document.querySelectorAll(`#${event.target.dataset.target} tbody tr`).forEach(row => row.hidden = !row.textContent.toLowerCase().includes(query)); } if (event.target.matches('.subject-options input')) { const form = event.target.closest('form'); const checked = [...form.querySelectorAll('.subject-options input:checked')]; form.querySelector('[data-subject-count]').textContent = checked.length; const exam = state.pageData.exams.find(item => item.id === form.examId.value); const fee = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fee || 0), 0); form.querySelector('[data-subject-fee]').textContent = `合计 ${money(fee)}`; } }); document.addEventListener('change', event => { if (event.target.matches('[data-action="school-select"]')) { const form = event.target.closest('form'); const classSelect = form?.querySelector('select[name="classId"]'); if (classSelect) { const classes = state.pageData?.classes || state.publicData.classes || []; classSelect.innerHTML = `${classes.filter(item => item.schoolId === event.target.value).map(item => ``).join('')}`; } } if (event.target.matches('[data-action="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-registration"]')) { const option = event.target.selectedOptions[0]; const select = document.querySelector('#resultSubject'); const subjects = option?.dataset.subjects ? JSON.parse(option.dataset.subjects) : []; select.innerHTML = `${subjects.map(subject => ``).join('')}`; } }); document.addEventListener('submit', async event => { const form = event.target.closest('form[data-form]'); if (!form) return; event.preventDefault(); const submit = form.querySelector('button[type="submit"]'); const original = submit?.innerHTML; if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; } try { const kind = form.dataset.form; if (kind === 'login') { const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); state.user = data.user; await refreshSession(); toast('登录成功', `欢迎,${data.user.displayName}`); navigate(`${data.user.role}/dashboard`); } else if (kind === 'register') { await api('/api/auth/register', { method: 'POST', body: formObject(form) }); toast('注册成功', '请登录后继续完善资料'); navigate('login'); } else if (kind === 'candidate-profile') { const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); state.profile = data.profile; 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 === '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 === '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), seatStart: Number(row.querySelector('[name="roomSeatStart"]').value), seatEnd: Number(row.querySelector('[name="roomSeatEnd"]').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 === 'batch-registration-numbers') { const result = await api('/api/admin/registration-numbers/batch', { method: 'POST', body: formObject(form) }); toast('批量生成完成', `已为 ${result.count} 条报名记录写入报名号`); 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}` : `/api/admin/center-change-requests/${body.businessId}`; await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } }); closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute(); } else if (kind === 'flow-transfer') { const body = formObject(form); await api(`/api/admin/workflow-instances/${body.id}/transfer`, { method: 'PATCH', body }); closeModal(); toast('流程已转交', '新责任人已收到待办'); renderRoute(); } else if (kind === 'flow-supervise') { const body = formObject(form); await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body }); closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute(); } else if (kind === 'notice-form') { const body = formObject(form); body.pinned = form.pinned.checked; await api('/api/admin/notices', { method: 'POST', body }); closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute(); } else if (kind === 'exam-form') { const body = formObject(form); body.subjects = body.subjects.split(/[,,]/).map(item => item.trim()).filter(Boolean); ['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(); } } 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 centerRoomEditor(room = {}) { return `
结构化考场
`; } function openCenterForm(center = null) { const schools = state.pageData.schools || []; const rooms = center?.rooms?.length ? center.rooms : [{}]; setModal(``); } 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 => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId)); const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || 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 subject = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; const subjectDetail = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; const change = instance.centerChange; 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.seatStart)}—${h(room.seatEnd)}`).join('')}
` : ''; setModal(`${changeSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); } function openCandidateReview(id) { const item = state.pageData.candidates.find(candidate => candidate.id === id); const canReview = state.user.adminLevel !== 'class' && item.status === 'pending'; setModal(`
证件号码
${h(item.idNumberMasked)}
联系电话
${h(item.phone)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
当前步骤
${h(item.workflow?.currentStepDetail?.name || '流程已结束')}
当前责任人
${h(item.workflow?.assignee?.displayName || '—')}
${canReview ? `` : ''}`); } function openRegistrationReview(id) { const reg = state.pageData.registrations.find(item => item.id === id); const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending'; setModal(`
报考科目

${reg.subjects.map(subject => `${h(subject.name)}`).join('')}

报名号
${h(reg.registrationNumber || '审批通过后生成')}
当前步骤
${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
${canReview ? `` : ``}`); } function openNoticeForm() { setModal(``); } 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 openExamForm(exam = null) { const editing = Boolean(exam); if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿'); setModal(``); } function openAdmitPreview(reg) { setModal(`
${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场
${h(reg.admitCard.room)}
座位号
${h(reg.admitCard.seat)}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}

考生可在 ${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); }