696 lines
98 KiB
JavaScript
696 lines
98 KiB
JavaScript
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: '<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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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-modal-backdrop><div class="modal-card" role="dialog" aria-modal="true">${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><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</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() {
|
||
const schools = state.publicData.schools || [];
|
||
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><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></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']
|
||
];
|
||
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 `<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">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : 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) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</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">${role === 'admin' && state.user?.adminLevel !== 'class' ? `<button class="notification-button" data-route="admin/flows">${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' ? adminTitle : `资料${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 === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
|
||
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${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), 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(data) {
|
||
const { profile, schools = [], classes = [], workflow } = data;
|
||
const step = workflow?.currentStepDetail;
|
||
return `<section class="panel form-panel">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<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><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></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 class="mono">${h(reg.registrationNumber || '审批通过后生成')}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</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: [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 `<section class="scope-banner"><span>${statusLabels[state.user.adminLevel]}</span><div><strong>${h(data.scopeLabel)}</strong><small>所有指标均已按当前管理员的数据范围过滤</small></div></section><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.pendingFlows ?? 0}</strong><em>${canFlow ? '进入流程中心办理' : '班级账号只读'}</em></div></article><article><span>${icons.chart}</span><div><small>已发布考试</small><strong>${m.publishedExams}</strong><em>全平台考试计划</em></div></article></div><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>${canFlow ? '当前工作入口' : '本班查询入口'}</h2><span>${h(data.scopeLabel)}</span></div><button data-route="admin/candidates"><i class="urgent">${m.pendingCandidates}</i><span><strong>${state.user.adminLevel === 'class' ? '查看本班考生' : '考生资料流程'}</strong><small>身份、学籍与联系方式</small></span>${icons.arrow}</button><button data-route="admin/registrations"><i>${m.pendingRegistrations}</i><span><strong>${state.user.adminLevel === 'class' ? '查看报名状态' : '考试报名流程'}</strong><small>考试、科目和报名号</small></span>${icons.arrow}</button>${canFlow ? `<button data-route="admin/flows"><i>${m.pendingFlows ?? 0}</i><span><strong>流程中心</strong><small>处理、转交与监督审批</small></span>${icons.arrow}</button>` : ''}<button data-route="admin/results"><i>成</i><span><strong>${state.user.adminLevel === 'super' ? '录入与发布成绩' : '查看范围内成绩'}</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) {
|
||
const readOnly = state.user.adminLevel === 'class';
|
||
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 || '未填写')} · ${h(item.phone)}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</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)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||
}
|
||
|
||
function adminRegistrations(registrations) {
|
||
const readOnly = state.user.adminLevel === 'class';
|
||
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?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '审批通过后生成')}</strong><small>${formatDate(reg.createdAt,true)}</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></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)}">${readOnly ? '查看' : '查看流程'}</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}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><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><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></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) {
|
||
const entry = state.user.adminLevel === 'super' ? `<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.registrationNumber || 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>` : '';
|
||
return `<div class="results-admin-grid ${state.user.adminLevel === 'super' ? '' : 'read-only'}">${entry}<section class="panel published-results"><div class="panel-title"><h2>${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 50).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 adminUsers(data) {
|
||
return `<section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`;
|
||
}
|
||
|
||
function adminCenters(data) {
|
||
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
|
||
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位号</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td class="mono">${h(room.seatStart)}—${h(room.seatEnd)}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||
const requests = data.changeRequests || [];
|
||
return `<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||
}
|
||
|
||
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 `<div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>组合报名号</h2><p>勾选字段并填写顺序;流水号为必选字段。</p></div><span>实时规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存并启用规则</button></form></section><aside class="rule-preview"><span>报名号样例</span><strong>${h(data.preview || '2026-HZ01-F-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>历史报名号不会因规则修改而变化。</small></aside></div><section class="panel batch-number-panel"><div class="batch-number-intro"><span>BATCH GENERATION</span><h2>批量生成报名号</h2><p>只处理“审核通过且报名号为空”的记录,可按考试和学校缩小范围;批次一次提交、整体写入。</p></div><form data-form="batch-registration-numbers"><label><span>考试范围</span><select name="examId"><option value="">全部考试</option>${(data.exams || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>学校范围</span><select name="schoolId"><option value="">全部学校</option>${(data.schools || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="batch-ready"><small>当前可生成</small><strong>${candidates.length}</strong><span>条</span></div><button class="solid-button" type="submit" ${candidates.length ? '' : 'disabled'}>按筛选条件批量生成</button></form><div class="batch-candidate-strip">${candidates.slice(0, 8).map(item => `<span><b>${h(item.candidateName)}</b><small>${h(item.schoolName)} · ${h(item.examName)}</small></span>`).join('') || '<p>当前没有缺失报名号的已审核记录。</p>'}${candidates.length > 8 ? `<em>另有 ${candidates.length - 8} 条</em>` : ''}</div></section>`;
|
||
}
|
||
|
||
function adminFlowDesign(workflows) {
|
||
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE' };
|
||
return `<div class="workflow-design-grid">${workflows.map(workflow => `<section class="panel workflow-designer"><header><div><span>${h(codes[workflow.businessType] || workflow.businessType)}</span><h2>${h(workflow.name)}</h2></div><button class="row-action" data-action="add-workflow-step" data-type="${h(workflow.businessType)}">添加步骤</button></header><form data-form="workflow-design" data-type="${h(workflow.businessType)}"><input name="name" value="${h(workflow.name)}" required><div class="workflow-step-editor" data-workflow-steps>${workflow.steps.map(step => workflowStepEditor(step)).join('')}</div><button class="solid-button" type="submit">保存流程</button></form></section>`).join('')}</div>`;
|
||
}
|
||
|
||
function workflowStepEditor(step = {}) {
|
||
return `<div class="workflow-step-row"><i></i><input name="stepName" value="${h(step.name)}" placeholder="步骤名称" required><select name="stepLevel"><option value="school" ${step.adminLevel === 'school' ? 'selected' : ''}>校级管理员</option><option value="super" ${step.adminLevel === 'super' ? 'selected' : ''}>超级管理员</option></select><button type="button" data-action="remove-workflow-step" aria-label="移除步骤">×</button></div>`;
|
||
}
|
||
|
||
function adminFlows(data) {
|
||
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
|
||
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更' };
|
||
return `<div class="workflow-board">${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 `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}</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;
|
||
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(`<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 === '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 = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
||
}
|
||
}
|
||
if (event.target.matches('[data-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 = `<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 === '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(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`);
|
||
}
|
||
|
||
function centerRoomEditor(room = {}) {
|
||
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label><span>座位起号 *</span><input name="roomSeatStart" type="number" min="1" required value="${h(room.seatStart || 1)}"></label><label><span>座位止号 *</span><input name="roomSeatEnd" type="number" min="1" required value="${h(room.seatEnd || 30)}"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
|
||
}
|
||
|
||
function openCenterForm(center = null) {
|
||
const schools = state.pageData.schools || [];
|
||
const rooms = center?.rooms?.length ? center.rooms : [{}];
|
||
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位区间与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">提交审批</button></div></form>`);
|
||
}
|
||
|
||
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 => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).join('');
|
||
const 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 ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatStart)}—${h(room.seatEnd)}</small></span>`).join('')}</div></section>` : '';
|
||
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">确认处理</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||
}
|
||
|
||
function openCandidateReview(id) {
|
||
const item = state.pageData.candidates.find(candidate => candidate.id === id);
|
||
const canReview = state.user.adminLevel !== 'class' && item.status === 'pending';
|
||
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${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.idNumberMasked)}</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.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||
}
|
||
|
||
function openRegistrationReview(id) {
|
||
const reg = state.pageData.registrations.find(item => item.id === id);
|
||
const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending';
|
||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>报名号</dt><dd class="mono">${h(reg.registrationNumber || '审批通过后生成')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : `<div class="modal-foot">${state.user.adminLevel === 'super' && reg.status === 'approved' && !reg.registrationNumber ? `<button class="ghost-button" data-action="generate-registration-number" data-id="${h(reg.id)}">生成报名号</button>` : ''}<button class="solid-button" data-action="close-modal">关闭</button></div>`}`);
|
||
}
|
||
|
||
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 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(`<div class="modal-head"><div><span>${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'}</span><h2>${editing ? '编辑考试草稿' : '创建考试与科目'}</h2><p>${editing ? '补全考试信息和科目后,可保存草稿或直接发布。' : '创建后可选择直接发布或先保存为草稿。'}</p></div><button type="button" data-action="close-modal" aria-label="关闭弹窗">×</button></div><form class="modal-form wide-form" data-form="exam-form">${editing ? `<input type="hidden" name="id" value="${h(exam.id)}">` : ''}<div class="field-row"><label><span>考试名称 *</span><input name="name" required value="${h(exam?.name)}" placeholder="例如:2027 年春季学业考试"></label><label><span>考试代码</span><input name="code" value="${h(exam?.code)}" placeholder="自动生成或手动填写"></label></div><label><span>考试说明</span><textarea name="description" rows="2" placeholder="报名对象、考试范围等">${h(exam?.description)}</textarea></label><div class="field-row"><label><span>报名开始 *</span><input name="registrationStart" type="datetime-local" value="${dateTimeLocal(exam?.registrationStart)}" required></label><label><span>报名结束 *</span><input name="registrationEnd" type="datetime-local" value="${dateTimeLocal(exam?.registrationEnd)}" required></label></div><div class="field-row"><label><span>考试开始 *</span><input name="examStart" type="datetime-local" value="${dateTimeLocal(exam?.examStart)}" required></label><label><span>考试结束 *</span><input name="examEnd" type="datetime-local" value="${dateTimeLocal(exam?.examEnd)}" required></label></div><div class="field-row"><label><span>准考证下载开始 *</span><input name="admitDownloadStart" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadStart)}" required></label><label><span>准考证下载结束 *</span><input name="admitDownloadEnd" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadEnd)}" required></label></div><label><span>考试科目 *</span><input name="subjects" required value="${h(exam?.subjects?.map(subject => subject.name).join(','))}" placeholder="用逗号分隔,例如:语文,数学,英语"></label><div class="field-row"><label><span>考点说明</span><input name="location" value="${h(exam?.location)}" placeholder="例如:全市指定考点"></label><label><span>${editing ? '保存状态' : '创建状态'}</span><select name="status"><option value="draft">保存为草稿</option><option value="published">立即发布</option></select></label></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存考试' : '创建考试'}</button></div></form>`);
|
||
}
|
||
|
||
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);
|
||
}
|