This commit is contained in:
2026-07-19 21:15:09 +08:00 Unverified
commit de4d0961e4
9 changed files with 1516 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
const state = {
user: null,
profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} },
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: '待缴费'
};
const icons = {
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
};
function h(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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 `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
}
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 = `<div class="modal-layer" data-action="close-modal"><div class="modal-card" role="dialog" aria-modal="true" onclick="event.stopPropagation()">${content}</div></div>`;
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
}
function closeModal() {
modalRoot.innerHTML = '';
}
function brand() {
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
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()}<main class="public-main">
<section class="hero">
<div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>每一次应考,<br><em>都有清晰的下一步。</em></h1><p class="hero-lead">从身份核验、选科报名到准考证与成绩查询,所有考试事项集中办理,进度实时可见。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/exams">继续报名 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="register">创建考生账号 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>从注册到查分,只需五步</h2></div><p>每一步都会在考生中心显示当前状态和下一项操作。</p></div><div class="flow-track">${[['01','注册并填写资料','使用手机号注册,完善实名信息。'],['02','等待资料审核','管理员核验身份与学籍信息。'],['03','选择考试科目','在报名期内自主勾选报考科目。'],['04','下载准考证','管理员生成后,在开放期内下载。'],['05','查询考试成绩','成绩发布后登录平台查询。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`;
}
function renderHeroTicket(exam) {
const status = exam.registrationState;
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
}
function renderNoticeRow(notice) {
return `<button class="notice-row" data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
}
function renderPublicExam(exam) {
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
}
function renderAuth(kind) {
const login = kind === 'login';
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '欢迎回来,' : '从这里,'}<br><em>${login ? '继续你的考试进程。' : '开始你的应考旅程。'}</em></h1><p>统一管理报名、审核、准考证与成绩,不错过每一个关键节点。</p></div><div class="auth-quote"><span>考试服务承诺</span><p>资料有状态、报名有回执、下载有时间、成绩有依据。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CREATE ACCOUNT'}</p><h2>${login ? '登录衡准' : '考生自主注册'}</h2><p>${login ? '使用考生账号或管理员账号进入系统。' : '请填写真实身份信息,注册后由管理员审核。'}</p>${login ? loginForm() : registerForm()}<div class="auth-switch">${login ? '还没有考生账号?' : '已经注册过?'}<button data-route="${login ? 'register' : 'login'}">${login ? '立即注册' : '返回登录'}</button></div>${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:13800138000 / Candidate123!</button><button data-action="fill-demo" data-type="admin">管理员:admin / Admin123!</button></div>` : ''}</div></section></main>`;
}
function loginForm() {
return `<form class="stack-form" data-form="login"><label><span>账号</span><input name="username" autocomplete="username" required placeholder="手机号或管理员账号"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="请输入登录密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
}
function registerForm() {
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别</span><select name="gender"><option value="">请选择</option><option>男</option><option>女</option></select></label></div><label><span>证件号码 *</span><input name="idNumber" required placeholder="居民身份证号码"></label><div class="field-row"><label><span>手机号 *</span><input name="phone" required placeholder="用于接收通知"></label><label><span>登录账号 *</span><input name="username" required placeholder="建议使用手机号"></label></div><div class="field-row"><label><span>就读学校</span><input name="school" placeholder="请输入学校名称"></label><label><span>班级</span><input name="grade" placeholder="例如:高三(2)班"></label></div><label><span>登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我确认以上信息真实有效,并同意用于考试报名与身份核验。</span></label><button class="solid-button large" type="submit">注册考生账号 ${icons.arrow}</button></form>`;
}
const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell']
];
const adminNav = [
['dashboard', '工作台', 'home'], ['candidates', '考生审核', 'users'], ['registrations', '报名审核', 'check'],
['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], ['results', '成绩发布', 'chart']
];
function portalShell(role, page, content, title, description) {
const nav = role === 'admin' ? adminNav : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>服务支持</span><strong>0518-8602 3158</strong><small>工作日 08:30—17:30</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><button class="notification-button" data-route="${role}/notices">${icons.bell}<i></i></button><span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? '系统管理员' : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
}
function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
return '';
}
function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
}
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.profile), 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 `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
}
function candidateProfile(profile) {
return `<section class="panel form-panel"><div class="form-section-title"><span>01</span><div><h2>实名信息</h2><p>姓名与证件号码须与有效证件完全一致。</p></div></div><form class="profile-form" data-form="candidate-profile"><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别</span><select name="gender"><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(profile?.idNumber)}"></label><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label></div><div class="form-section-title"><span>02</span><div><h2>学籍与联系信息</h2><p>用于资格审核和紧急情况联系。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><input name="school" required value="${h(profile?.school)}"></label><label><span>年级 / 班级</span><input name="grade" value="${h(profile?.grade)}"></label><label><span>电子邮箱</span><input name="email" type="email" value="${h(profile?.email)}"></label><label><span>常住地址</span><input name="address" value="${h(profile?.address)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>保存后资料状态将变为“待审核”。</p><button class="solid-button" type="submit">保存并提交审核</button></div></form></section>`;
}
function candidateExams(data) {
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>合计 ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
}
function candidateRegistrations(registrations) {
return registrations.length ? `<div class="registration-cards">${registrations.map(reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.status)}</header><div class="registration-info"><dl><div><dt>报名编号</dt><dd>${h(reg.id)}</dd></div><div><dt>报名时间</dt><dd>${formatDate(reg.createdAt, true)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '报名已进入审核队列,请耐心等待。' : '报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
}
function candidateAdmit(registrations) {
const cards = registrations.filter(reg => reg.admitCard);
return cards.length ? `<div class="admit-list">${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场 / 座位</dt><dd>${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`; }).join('')}</div>` : 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 `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>发布时间 ${formatDate(items[0].publishedAt, true)}</small></header><div class="score-grid">${items.map(item => `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 150</small></article>`).join('')}</div><footer><p>成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。</p><strong>已发布 ${items.length} 科</strong></footer></section>`).join('')}</div>`;
}
function candidateNotices(notices) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
}
async function renderAdmin(page) {
if (state.user?.role !== 'admin') return navigate('login');
const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
results: ['成绩发布', '录入单科成绩并控制是否对考生公开。']
};
if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
try {
const endpoint = page === 'admit' ? 'registrations' : 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)
}[page]();
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
function adminDashboard(data) {
const m = data.metrics;
return `<div class="admin-metrics"><article><span>${icons.users}</span><div><small>考生总数</small><strong>${m.candidates}</strong><em>${m.pendingCandidates} 人待审核</em></div></article><article><span>${icons.check}</span><div><small>考试报名</small><strong>${m.registrations}</strong><em>${m.pendingRegistrations} 条待审核</em></div></article><article><span>${icons.exam}</span><div><small>已发布考试</small><strong>${m.publishedExams}</strong><em>考试计划正常</em></div></article><article><span>${icons.bell}</span><div><small>已发布通知</small><strong>${m.notices}</strong><em>首页同步展示</em></div></article></div><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>当前待办</h2><span>按优先级排列</span></div><button data-route="admin/candidates"><i class="urgent">${m.pendingCandidates}</i><span><strong>考生资料待审核</strong><small>核验身份、学籍与联系方式</small></span>${icons.arrow}</button><button data-route="admin/registrations"><i>${m.pendingRegistrations}</i><span><strong>考试报名待审核</strong><small>确认选报科目和缴费信息</small></span>${icons.arrow}</button><button data-route="admin/admit"><i>${m.registrations}</i><span><strong>检查准考证生成</strong><small>为已通过报名分配考场座位</small></span>${icons.arrow}</button><button data-route="admin/results"><i>成</i><span><strong>录入与发布成绩</strong><small>仅正式发布后考生可见</small></span>${icons.arrow}</button></section><section class="panel audit-feed"><div class="panel-title"><h2>最近操作</h2><span>系统审计日志</span></div>${data.logs.map(log => `<div><span class="user-avatar">${h((log.actorName || '系').slice(0,1))}</span><p><strong>${h(log.actorName || '系统')} · ${h(log.action)}</strong><small>${h(log.detail)}</small></p><time>${formatDate(log.createdAt,true)}</time></div>`).join('') || '<p class="empty-state">暂无操作记录</p>'}</section></div>`;
}
function adminCandidates(candidates) {
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索姓名、证件号、学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>考生</th><th>证件号码</th><th>学校 / 班级</th><th>联系方式</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small>${h(item.gender || '未填写')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td>${h(item.phone)}<small>${h(item.email || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${badge(item.status)}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}">查看审核</button></td></tr>`).join('')}</tbody></table></div></section>`;
}
function adminRegistrations(registrations) {
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>报名时间</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.idNumber)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td><div class="table-chips">${reg.subjects.map(subject => `<span>${h(subject.name)}</span>`).join('')}</div></td><td>${formatDate(reg.createdAt,true)}</td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">审核报名</button></td></tr>`).join('')}</tbody></table></div></section>`;
}
function adminExams(exams) {
return `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></footer></article>`).join('')}</div>`;
}
function adminNotices(notices) {
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
}
function adminAdmit(registrations) {
const approved = registrations.filter(reg => reg.status === 'approved');
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号"></label><p>仅已通过报名审核的考生可生成准考证。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>准考证号</th><th>考点 / 考场</th><th>下载时间</th><th>操作</th></tr></thead><tbody>${approved.map(reg => `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.phone)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td>${reg.subjects.map(subject => h(subject.name)).join('、')}</td><td class="mono">${h(reg.admitCard?.number || '尚未生成')}</td><td>${reg.admitCard ? `${h(reg.admitCard.testCenter)}<small>${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号</small>` : '—'}</td><td>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</td><td><button class="row-action ${reg.admitCard ? '' : 'primary'}" data-action="generate-admit" data-id="${h(reg.id)}">${reg.admitCard ? '重新查看' : '生成准考证'}</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
}
function adminResults(data) {
return `<div class="results-admin-grid"><section class="panel result-entry"><div class="panel-title"><h2>录入单科成绩</h2><span>保存后可立即发布</span></div><form data-form="result-entry"><label><span>报名记录</span><select name="registrationId" data-action="result-registration" required><option value="">请选择考生和考试</option>${data.registrations.map(reg => `<option value="${h(reg.id)}" data-subjects='${h(JSON.stringify(reg.subjects.map(subject => ({id:subject.id,name:subject.name}))))}'>${h(reg.exam.name)} · ${h(reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span>成绩(0—150</span><input type="number" name="score" min="0" max="150" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将自动计算"></label></div><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section><section class="panel published-results"><div class="panel-title"><h2>最近成绩</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 12).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>`;
}
function emptyState(title, description, route, action) {
return `<section class="panel empty-panel"><span>${icons.ticket}</span><h2>${h(title)}</h2><p>${h(description)}</p>${route ? `<button class="solid-button" data-route="${h(route)}">${h(action)}</button>` : ''}</section>`;
}
function renderError(error) {
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`;
}
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;
}
document.addEventListener('click', async event => {
const routeTarget = event.target.closest('[data-route]');
if (routeTarget) {
event.preventDefault();
return navigate(routeTarget.dataset.route);
}
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;
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
}
if (action === 'fill-demo') {
const form = document.querySelector('[data-form="login"]');
form.username.value = target.dataset.type === 'admin' ? 'admin' : '13800138000';
form.password.value = target.dataset.type === 'admin' ? 'Admin123!' : 'Candidate123!';
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(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content"><p>${h(notice.content).replace(/\n/g, '</p><p>')}</p></article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
}
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
if (action === 'new-notice') return openNoticeForm();
if (action === 'new-exam') return openExamForm();
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
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="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 = `<option value="">请选择科目</option>${subjects.map(subject => `<option value="${h(subject.id)}">${h(subject.name)}</option>`).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 === '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());
await api('/api/admin/exams', { method: 'POST', body });
closeModal(); await refreshPublic(); toast('考试计划已创建', `${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 openCandidateReview(id) {
const item = state.pageData.candidates.find(candidate => candidate.id === id);
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>审核 ${h(item.name)} 的资料</h2><p>最后更新:${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumber)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email || '未填写')}</dd></div><div><dt>联系地址</dt><dd>${h(item.address || '未填写')}</dd></div></dl></div><form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">审核通过</option><option value="rejected">退回修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认审核</button></div></form>`);
}
function openRegistrationReview(id) {
const reg = state.pageData.registrations.find(item => item.id === id);
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>审核考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>资料状态</dt><dd>${badge(reg.candidate?.status)}</dd></div><div><dt>报名时间</dt><dd>${formatDate(reg.createdAt,true)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div><form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">报名通过并确认缴费</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认审核</button></div></form>`);
}
function openNoticeForm() {
setModal(`<div class="modal-head"><div><span>NEW NOTICE</span><h2>发布通知公告</h2><p>发布后将立即显示在公开首页和考生中心。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="notice-form"><div class="field-row"><label><span>通知分类</span><select name="category"><option>报名通知</option><option>考试须知</option><option>考点公告</option><option>成绩通知</option><option>系统公告</option></select></label><label><span>发布方式</span><select name="status"><option value="published">立即发布</option><option value="draft">保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示,建议 60 字以内"></label><label><span>通知正文 *</span><textarea name="content" rows="7" required placeholder="请输入完整通知内容"></textarea></label><label class="agreement"><input type="checkbox" name="pinned"><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">保存通知</button></div></form>`);
}
function openExamForm() {
setModal(`<div class="modal-head"><div><span>NEW EXAM</span><h2>创建考试与科目</h2><p>创建后可选择直接发布或先保存为草稿。</p></div><button data-action="close-modal">×</button></div><form class="modal-form wide-form" data-form="exam-form"><div class="field-row"><label><span>考试名称 *</span><input name="name" required placeholder="例如:2027 年春季学业考试"></label><label><span>考试代码</span><input name="code" placeholder="自动生成或手动填写"></label></div><label><span>考试说明</span><textarea name="description" rows="2" placeholder="报名对象、考试范围等"></textarea></label><div class="field-row"><label><span>报名开始 *</span><input name="registrationStart" type="datetime-local" required></label><label><span>报名结束 *</span><input name="registrationEnd" type="datetime-local" required></label></div><div class="field-row"><label><span>考试开始 *</span><input name="examStart" type="datetime-local" required></label><label><span>考试结束 *</span><input name="examEnd" type="datetime-local" required></label></div><div class="field-row"><label><span>准考证下载开始 *</span><input name="admitDownloadStart" type="datetime-local" required></label><label><span>准考证下载结束 *</span><input name="admitDownloadEnd" type="datetime-local" required></label></div><label><span>考试科目 *</span><input name="subjects" required placeholder="用逗号分隔,例如:语文,数学,英语"></label><div class="field-row"><label><span>考点说明</span><input name="location" placeholder="例如:全市指定考点"></label><label><span>创建状态</span><select name="status"><option value="draft">保存为草稿</option><option value="published">立即发布</option></select></label></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建考试</button></div></form>`);
}
function openAdmitPreview(reg) {
setModal(`<div class="modal-head"><div><span>ADMISSION CARD</span><h2>准考证已生成</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="admit-preview"><strong>${h(reg.admitCard.number)}</strong><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场</dt><dd>${h(reg.admitCard.room)}</dd></div><div><dt>座位号</dt><dd>${h(reg.admitCard.seat)}</dd></div><div><dt>生成时间</dt><dd>${formatDate(reg.admitCard.generatedAt,true)}</dd></div></dl><p>考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">完成</button></div>`);
}
window.addEventListener('hashchange', renderRoute);
window.addEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); });
try {
await Promise.all([refreshPublic(), refreshSession()]);
await renderRoute();
} catch (error) {
renderError(error);
}