commit de4d0961e421f9184418517172dc36605d596648 Author: biss Date: Sun Jul 19 21:15:09 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62b4701 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/db.json +data/test-db.json +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..78cd086 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# 衡准 · 考试信息管理系统 + +一个完整可运行的双角色考试信息管理系统,使用零依赖 Node.js 后端和 JSON 持久化,开箱即可运行。 + +## 已实现功能 + +### 公开服务首页 + +- 通知公告首页展示与详情阅读 +- 已发布考试、报名时间、考试时间和科目展示 +- 考试服务办理流程说明 +- 考生注册与双角色登录入口 + +### 考生中心 + +- 考生自主注册、登录和实名资料维护 +- 资料审核状态与管理员审核意见 +- 查看开放考试并自主选择多个报考科目 +- 查看报名、缴费和审核状态 +- 准考证生成状态、开放时间与下载 +- 已发布成绩查询 +- 通知公告中心 + +### 管理后台 + +- 考务指标与审计日志 +- 考生资料审核、通过或退回修改 +- 考试报名及科目审核 +- 创建考试、配置报名/考试/准考证时间与多个科目 +- 通知发布、草稿、撤回及首页置顶 +- 为审核通过的报名生成准考证号、考点、考场和座位 +- 单科成绩录入、等级计算与发布控制 +- 管理员与考生接口权限隔离 + +### 系统能力 + +- PBKDF2 加盐密码哈希 +- HttpOnly、SameSite 登录 Cookie +- 服务端角色权限校验 +- JSON 文件持久化 +- 关键管理操作审计日志 +- 桌面端与移动端响应式布局 +- 零第三方运行时依赖 + +## 运行 + +需要 Node.js 18 或更高版本。 + +```powershell +npm start +``` + +打开 。 + +首次运行会自动创建 `data/db.json`。 + +## 演示账号 + +| 角色 | 账号 | 密码 | +| --- | --- | --- | +| 管理员 | `admin` | `Admin123!` | +| 考生 | `13800138000` | `Candidate123!` | + +## 自动化测试 + +```powershell +npm test +``` + +测试使用独立临时数据库,覆盖注册、审核、多科目报名、准考证生成与下载、通知发布、成绩发布和权限隔离完整流程。 + +## 项目结构 + +```text +index.html 页面入口 +styles.css 公共首页、考生端、管理端响应式样式 +app.js 前端路由、状态和业务交互 +server.mjs HTTP 服务、认证、权限与全部业务 API +tests/system.test.mjs 端到端系统测试 +data/db.json 运行后生成的持久化数据 +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..841ca71 --- /dev/null +++ b/app.js @@ -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: '', + user: '', + exam: '', + ticket: '', + chart: '', + bell: '', + users: '', + check: '', + plus: '', + logout: '', + menu: '', + search: '', + arrow: '' +}; + +function h(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +function formatDate(value, withTime = false) { + if (!value) return '待定'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return h(value); + return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); +} + +function dateRange(start, end) { + return `${formatDate(start)} — ${formatDate(end)}`; +} + +function badge(status) { + return `${h(statusLabels[status] || status)}`; +} + +function money(value) { + return `¥${Number(value || 0).toFixed(2)}`; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + credentials: 'same-origin', + headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + ...options, + body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body + }); + const type = response.headers.get('content-type') || ''; + const data = type.includes('application/json') ? await response.json() : await response.text(); + if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试'); + return data; +} + +let toastTimer; +function toast(title, message = '') { + const element = document.querySelector('#toast'); + element.querySelector('strong').textContent = title; + element.querySelector('small').textContent = message; + element.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => element.classList.remove('show'), 2800); +} + +function setModal(content) { + modalRoot.innerHTML = ``; + setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30); +} + +function closeModal() { + modalRoot.innerHTML = ''; +} + +function brand() { + return `衡准EXAM SERVICE`; +} + +function publicHeader() { + return `
${brand()}
`; +} + +function renderHome() { + const { notices, exams, stats, organization } = state.publicData; + const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; + const topNotice = notices[0]; + app.innerHTML = `${publicHeader()}
+
+
+
最新

HAIZHOU EXAMINATION SERVICE

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

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

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

NOTICE BOARD

通知公告

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

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

OPEN EXAMINATIONS

考试报名

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

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

SERVICE FLOW

从注册到查分,只需五步

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

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

${item[1]}

${item[2]}

`).join('')}
+
${brand()}

${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}

本平台展示数据仅用于系统演示
`; +} + +function renderHeroTicket(exam) { + const status = exam.registrationState; + return `
${badge(status)}${h(exam.code)}

UPCOMING EXAM

${h(exam.name)}

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

${h(exam.name)}

${h(exam.description)}

报名${dateRange(exam.registrationStart, exam.registrationEnd)}考试${dateRange(exam.examStart, exam.examEnd)}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名
`; +} + +function renderAuth(kind) { + const login = kind === 'login'; + app.innerHTML = `
${brand()}

CANDIDATE SERVICE

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

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

考试服务承诺

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

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

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

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

${login ? loginForm() : registerForm()}
${login ? '还没有考生账号?' : '已经注册过?'}
${login ? `
演示账号
` : ''}
`; +} + +function loginForm() { + return `
`; +} + +function registerForm() { + return `
`; +} + +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 `
${roleName}/${h(title)}
${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? '系统管理员' : `资料${statusLabels[state.profile?.status] || '未完善'}`}

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

${h(title)}

${h(description)}

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

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

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


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

我的应考进度

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

最近通知

${data.notices.map(notice => ``).join('')}
`; +} + +function candidateProfile(profile) { + return `
01

实名信息

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

02

学籍与联系信息

用于资格审核和紧急情况联系。

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

${h(profile.reviewNote)}

` : ''}

保存后资料状态将变为“待审核”。

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

${h(exam.name)}

${h(exam.description)}

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

科目安排尚未发布

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

${h(reg.exam.name)}

${badge(reg.status)}
报名编号
${h(reg.id)}
报名时间
${formatDate(reg.createdAt, true)}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '报名已进入审核队列,请耐心等待。' : '报名已经确认,请留意准考证下载通知。'}

${reg.admitCard ? `` : ''}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); +} + +function candidateAdmit(registrations) { + const cards = registrations.filter(reg => reg.admitCard); + return cards.length ? `
${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

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

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
${items.map(item => `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 150
`).join('')}

成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。

已发布 ${items.length} 科
`).join('')}
`; +} + +function candidateNotices(notices) { + return `
${notices.map(notice => ``).join('')}
`; +} + +async function renderAdmin(page) { + if (state.user?.role !== 'admin') return navigate('login'); + const meta = { + dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], + registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], + notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'], + results: ['成绩发布', '录入单科成绩并控制是否对考生公开。'] + }; + 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 `
${icons.users}
考生总数${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
已发布考试${m.publishedExams}考试计划正常
${icons.bell}
已发布通知${m.notices}首页同步展示

当前待办

按优先级排列

最近操作

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

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

`).join('') || '

暂无操作记录

'}
`; +} + +function adminCandidates(candidates) { + return `
${candidates.map(item => ``).join('')}
考生证件号码学校 / 班级联系方式更新时间状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.gender || '未填写')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${h(item.phone)}${h(item.email || '')}${formatDate(item.updatedAt,true)}${badge(item.status)}
`; +} + +function adminRegistrations(registrations) { + return `
${registrations.map(reg => ``).join('')}
考生考试报考科目报名时间缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.idNumber)}
${h(reg.exam.name)}${h(reg.exam.code)}
${reg.subjects.map(subject => `${h(subject.name)}`).join('')}
${formatDate(reg.createdAt,true)}${badge(reg.paymentStatus)}${badge(reg.status)}
`; +} + +function adminExams(exams) { + return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

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

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

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

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

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

录入单科成绩

保存后可立即发布

最近成绩

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

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

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

还没有成绩记录

'}
`; +} + +function emptyState(title, description, route, action) { + return `
${icons.ticket}

${h(title)}

${h(description)}

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

页面暂时无法加载

${h(error.message)}

`; +} + +function navigate(route) { + location.hash = route; + if (location.hash.slice(1) === route) renderRoute(); +} + +async function renderRoute() { + closeModal(); + window.scrollTo({ top: 0, behavior: 'instant' }); + const route = location.hash.slice(1) || 'home'; + const [section, page = 'dashboard'] = route.split('/'); + if (section === 'home') renderHome(); + else if (section === 'login' || section === 'register') renderAuth(section); + else if (section === 'candidate') await renderCandidate(page); + else if (section === 'admin') await renderAdmin(page); + else navigate('home'); +} + +function formObject(form) { + return Object.fromEntries(new FormData(form).entries()); +} + +async function refreshPublic() { + state.publicData = await api('/api/public/home'); +} + +async function refreshSession() { + const session = await api('/api/auth/me'); + state.user = session.user; + state.profile = session.profile; +} + +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(`

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

')}

`); return; + } + if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } + if (action === 'new-notice') return openNoticeForm(); + if (action === 'new-exam') return openExamForm(); + if (action === '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 = `${subjects.map(subject => ``).join('')}`; + } +}); + +document.addEventListener('submit', async event => { + const form = event.target.closest('form[data-form]'); + if (!form) return; + event.preventDefault(); + const submit = form.querySelector('button[type="submit"]'); + const original = submit?.innerHTML; + if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; } + try { + const kind = form.dataset.form; + if (kind === 'login') { + const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); + state.user = data.user; await refreshSession(); + toast('登录成功', `欢迎,${data.user.displayName}`); navigate(`${data.user.role}/dashboard`); + } else if (kind === 'register') { + await api('/api/auth/register', { method: 'POST', body: formObject(form) }); + toast('注册成功', '请登录后继续完善资料'); navigate('login'); + } else if (kind === 'candidate-profile') { + const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); + state.profile = data.profile; toast('资料已提交', '管理员审核后会更新状态'); renderRoute(); + } else if (kind === 'exam-registration') { + const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; + if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); + await api('/api/candidate/registrations', { method: 'POST', body }); + toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute(); + } else if (kind === 'candidate-review') { + const body = formObject(form); + await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body }); + closeModal(); toast(body.status === 'approved' ? '资料审核通过' : '资料已退回', '考生端状态已同步'); renderRoute(); + } else if (kind === 'registration-review') { + const body = formObject(form); + await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body }); + closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute(); + } else if (kind === '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(`
证件号码
${h(item.idNumber)}
联系电话
${h(item.phone)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
电子邮箱
${h(item.email || '未填写')}
联系地址
${h(item.address || '未填写')}
`); +} + +function openRegistrationReview(id) { + const reg = state.pageData.registrations.find(item => item.id === id); + setModal(`
报考科目

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

资料状态
${badge(reg.candidate?.status)}
报名时间
${formatDate(reg.createdAt,true)}
缴费状态
${badge(reg.paymentStatus)}
`); +} + +function openNoticeForm() { + setModal(``); +} + +function openExamForm() { + setModal(``); +} + +function openAdmitPreview(reg) { + setModal(`
${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场
${h(reg.admitCard.room)}
座位号
${h(reg.admitCard.seat)}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}

考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); +} + +window.addEventListener('hashchange', renderRoute); +window.addEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); }); + +try { + await Promise.all([refreshPublic(), refreshSession()]); + await renderRoute(); +} catch (error) { + renderError(error); +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..a325095 --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + 衡准 · 考试信息管理系统 + + + +
+
+
+ 衡准 + 系统正在加载 +
+
+
+
+ +
操作成功更改已保存
+
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..bbba093 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "hengzhun-exam-system", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "test": "node tests/system.test.mjs" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..1ee6483 --- /dev/null +++ b/server.mjs @@ -0,0 +1,560 @@ +import { createServer } from 'node:http'; +import { readFile, writeFile, mkdir, access } from 'node:fs/promises'; +import { extname, join, normalize, resolve } from 'node:path'; +import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; + +const root = resolve(process.cwd()); +const port = Number(process.env.PORT || 4173); +const dbPath = resolve(process.env.EXAM_DB_PATH || join(root, 'data', 'db.json')); +const sessions = new Map(); +const staticFiles = new Set(['/index.html', '/styles.css', '/app.js']); +const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml' +}; + +function nowIso() { + return new Date().toISOString(); +} + +function uid(prefix) { + return `${prefix}_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`; +} + +function hashPassword(password, salt = randomBytes(16).toString('hex')) { + const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex'); + return `${salt}:${hash}`; +} + +function verifyPassword(password, stored) { + const [salt, expected] = String(stored).split(':'); + if (!salt || !expected) return false; + const actual = pbkdf2Sync(password, salt, 120000, 32, 'sha256'); + const expectedBuffer = Buffer.from(expected, 'hex'); + return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer); +} + +function seedDatabase() { + const adminId = 'usr_admin'; + const candidateId = 'usr_demo'; + const examId = 'exam_autumn_2026'; + const registrationId = 'reg_demo_2026'; + return { + meta: { version: 1, createdAt: nowIso() }, + organization: { + name: '海州市教育考试中心', + code: 'HZ-EDU-032', + phone: '0518-8602 3158', + address: '海州市清河区文教路 18 号' + }, + users: [ + { id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', displayName: '林老师', createdAt: nowIso() }, + { id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', createdAt: nowIso() } + ], + candidateProfiles: [ + { + id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', + phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', + address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', + status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z' + } + ], + notices: [ + { id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' }, + { id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' }, + { id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' } + ], + exams: [ + { + id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。', + registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z', + examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z', + admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z', + location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z', + subjects: [ + { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 }, + { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 }, + { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, + { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, + { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 }, + { id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 }, + { id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 } + ] + }, + { + id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。', + registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z', + examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z', + admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z', + location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: [] + } + ], + registrations: [ + { + id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], + status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', + admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' } + } + ], + results: [ + { id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }, + { id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' } + ], + auditLogs: [ + { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } + ] + }; +} + +async function ensureDatabase() { + try { + await access(dbPath); + } catch { + await mkdir(resolve(dbPath, '..'), { recursive: true }); + await writeFile(dbPath, JSON.stringify(seedDatabase(), null, 2), 'utf8'); + } +} + +async function readDb() { + await ensureDatabase(); + return JSON.parse(await readFile(dbPath, 'utf8')); +} + +async function writeDb(db) { + await mkdir(resolve(dbPath, '..'), { recursive: true }); + await writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8'); +} + +function sendJson(response, status, payload, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); + response.end(JSON.stringify(payload)); +} + +function sendError(response, status, message, details) { + sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) }); +} + +async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw Object.assign(new Error('请求数据格式不正确'), { status: 400 }); + } +} + +function parseCookies(request) { + return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf('='); + return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))]; + })); +} + +async function currentUser(request) { + const token = parseCookies(request).hz_session; + const session = token && sessions.get(token); + if (!session || session.expiresAt < Date.now()) { + if (token) sessions.delete(token); + return null; + } + const db = await readDb(); + return db.users.find(user => user.id === session.userId) || null; +} + +function safeUser(user) { + return { id: user.id, username: user.username, role: user.role, displayName: user.displayName }; +} + +async function requireUser(request, response, role) { + const user = await currentUser(request); + if (!user) { + sendError(response, 401, '请先登录'); + return null; + } + if (role && user.role !== role) { + sendError(response, 403, '当前账号无权执行此操作'); + return null; + } + return user; +} + +function cleanText(value, max = 200) { + return String(value ?? '').trim().slice(0, max); +} + +function maskId(value) { + const text = String(value || ''); + return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text; +} + +function publicExam(exam) { + const now = Date.now(); + const start = new Date(exam.registrationStart).getTime(); + const end = new Date(exam.registrationEnd).getTime(); + return { + ...exam, + registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open' + }; +} + +function examRegistrationView(db, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)); + return { ...registration, exam, subjects }; +} + +function logAction(db, user, action, detail) { + db.auditLogs.unshift({ id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() }); + db.auditLogs = db.auditLogs.slice(0, 200); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +function admitCardHtml(db, user, profile, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); + const rows = subjects.map(subject => `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(registration.admitCard.room)}`).join(''); + return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
考点${escapeHtml(registration.admitCard.testCenter)}
考场 / 座位${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}
${rows}
科目日期时间考场
考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。
`; +} + +async function handlePublic(pathname, response) { + const db = await readDb(); + if (pathname === '/api/public/home') { + const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)); + const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); + return sendJson(response, 200, { ok: true, organization: db.organization, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } }); + } + const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/); + if (noticeMatch) { + const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published'); + return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布'); + } + return false; +} + +async function handleAuth(request, response, pathname) { + if (request.method === 'GET' && pathname === '/api/auth/me') { + const user = await currentUser(request); + if (!user) return sendJson(response, 200, { ok: true, user: null }); + const db = await readDb(); + const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null; + return sendJson(response, 200, { ok: true, user: safeUser(user), profile }); + } + if (request.method === 'POST' && pathname === '/api/auth/register') { + const body = await readJson(request); + const username = cleanText(body.username, 50); + const password = String(body.password || ''); + const name = cleanText(body.name, 30); + const idNumber = cleanText(body.idNumber, 30); + const phone = cleanText(body.phone, 30); + if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息'); + if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); + const db = await readDb(); + if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册'); + if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册'); + const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: cleanText(body.school, 80), grade: cleanText(body.grade, 50), address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() }; + db.users.push(user); + db.candidateProfiles.push(profile); + await writeDb(db); + return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' }); + } + if (request.method === 'POST' && pathname === '/api/auth/login') { + const body = await readJson(request); + const db = await readDb(); + const user = db.users.find(item => item.username.toLowerCase() === cleanText(body.username, 50).toLowerCase()); + if (!user || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确'); + const token = randomBytes(32).toString('hex'); + sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); + return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` }); + } + if (request.method === 'POST' && pathname === '/api/auth/logout') { + const token = parseCookies(request).hz_session; + if (token) sessions.delete(token); + return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' }); + } + return false; +} + +async function handleCandidate(request, response, pathname) { + if (!pathname.startsWith('/api/candidate/')) return false; + const user = await requireUser(request, response, 'candidate'); + if (!user) return true; + const db = await readDb(); + const profile = db.candidateProfiles.find(item => item.userId === user.id); + + if (request.method === 'GET' && pathname === '/api/candidate/dashboard') { + const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)); + const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId)); + const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5); + return sendJson(response, 200, { ok: true, profile, registrations, results, notices }); + } + if (request.method === 'GET' && pathname === '/api/candidate/profile') return sendJson(response, 200, { ok: true, profile }); + if (request.method === 'PUT' && pathname === '/api/candidate/profile') { + const body = await readJson(request); + const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'school', 'grade', 'address', 'emergencyContact', 'emergencyPhone']; + for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80); + if (!profile.name || !profile.idNumber || !profile.phone || !profile.school) return sendError(response, 400, '姓名、证件号码、手机号和学校为必填项'); + if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用'); + profile.status = 'pending'; + profile.reviewNote = ''; + profile.updatedAt = nowIso(); + const persistedUser = db.users.find(item => item.id === user.id); + if (persistedUser) persistedUser.displayName = profile.name; + await writeDb(db); + return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' }); + } + if (request.method === 'GET' && pathname === '/api/candidate/exams') { + const registrations = db.registrations.filter(item => item.userId === user.id); + const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null })); + return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams }); + } + if (request.method === 'GET' && pathname === '/api/candidate/registrations') { + return sendJson(response, 200, { ok: true, registrations: db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)) }); + } + if (request.method === 'POST' && pathname === '/api/candidate/registrations') { + if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试'); + const body = await readJson(request); + const exam = db.exams.find(item => item.id === body.examId && item.status === 'published'); + if (!exam) return sendError(response, 404, '考试不存在或尚未发布'); + const state = publicExam(exam).registrationState; + if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止'); + if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试'); + const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])]; + if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目'); + const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), admitCard: null }; + db.registrations.push(registration); + await writeDb(db); + return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); + } + if (request.method === 'GET' && pathname === '/api/candidate/results') { + const registrations = db.registrations.filter(item => item.userId === user.id); + const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => { + const registration = registrations.find(reg => reg.id === result.registrationId); + const exam = db.exams.find(item => item.id === registration.examId); + const subject = exam.subjects.find(item => item.id === result.subjectId); + return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId }; + }); + return sendJson(response, 200, { ok: true, results }); + } + const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/); + if (request.method === 'GET' && admitMatch) { + const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id); + if (!registration || !registration.admitCard) return sendError(response, 404, '准考证尚未生成'); + const exam = db.exams.find(item => item.id === registration.examId); + const now = Date.now(); + if (now < new Date(exam.admitDownloadStart).getTime()) return sendError(response, 403, '准考证下载尚未开放'); + if (now > new Date(exam.admitDownloadEnd).getTime()) return sendError(response, 403, '准考证下载时间已结束'); + const html = admitCardHtml(db, user, profile, registration); + const filename = encodeURIComponent(`${exam.name}-${profile.name}-准考证.html`); + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' }); + response.end(html); + return true; + } + return sendError(response, 404, '考生功能接口不存在'); +} + +async function handleAdmin(request, response, pathname) { + if (!pathname.startsWith('/api/admin/')) return false; + const user = await requireUser(request, response, 'admin'); + if (!user) return true; + const db = await readDb(); + + if (request.method === 'GET' && pathname === '/api/admin/dashboard') { + const pendingCandidates = db.candidateProfiles.filter(item => item.status === 'pending').length; + const pendingRegistrations = db.registrations.filter(item => item.status === 'pending').length; + return sendJson(response, 200, { ok: true, metrics: { candidates: db.candidateProfiles.length, pendingCandidates, registrations: db.registrations.length, pendingRegistrations, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, logs: db.auditLogs.slice(0, 8) }); + } + if (request.method === 'GET' && pathname === '/api/admin/candidates') { + const candidates = db.candidateProfiles.map(profile => ({ ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username })); + return sendJson(response, 200, { ok: true, candidates }); + } + const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); + if (request.method === 'PATCH' && candidateMatch) { + const body = await readJson(request); + const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]); + if (!profile) return sendError(response, 404, '考生资料不存在'); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); + profile.status = body.status; + profile.reviewNote = cleanText(body.reviewNote, 300); + profile.reviewedAt = nowIso(); + profile.reviewerId = user.id; + logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`); + await writeDb(db); + return sendJson(response, 200, { ok: true, profile }); + } + if (request.method === 'GET' && pathname === '/api/admin/registrations') { + const registrations = db.registrations.map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null }; + }); + return sendJson(response, 200, { ok: true, registrations }); + } + const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/); + if (request.method === 'PATCH' && registrationMatch) { + const body = await readJson(request); + const registration = db.registrations.find(item => item.id === registrationMatch[1]); + if (!registration) return sendError(response, 404, '报名记录不存在'); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); + registration.status = body.status; + registration.reviewNote = cleanText(body.reviewNote, 300); + registration.reviewedAt = nowIso(); + if (body.status === 'approved') registration.paymentStatus = 'paid'; + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); + await writeDb(db); + return sendJson(response, 200, { ok: true, registration }); + } + const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/); + if (request.method === 'POST' && admitMatch) { + const registration = db.registrations.find(item => item.id === admitMatch[1]); + if (!registration) return sendError(response, 404, '报名记录不存在'); + if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证'); + if (!registration.admitCard) { + const exam = db.exams.find(item => item.id === registration.examId); + const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0'); + registration.admitCard = { + number: `${exam.code.replace(/\D/g, '').slice(-4) || '2026'}-${sequence}`, + testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80), + room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`, + seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'), + generatedAt: nowIso() + }; + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`); + await writeDb(db); + } + return sendJson(response, 200, { ok: true, admitCard: registration.admitCard }); + } + if (request.method === 'GET' && pathname === '/api/admin/exams') return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) }); + if (request.method === 'POST' && pathname === '/api/admin/exams') { + const body = await readJson(request); + const name = cleanText(body.name, 100); + if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); + const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/); + const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 })); + if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目'); + const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() }; + db.exams.push(exam); + logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`); + await writeDb(db); + return sendJson(response, 201, { ok: true, exam }); + } + const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/); + if (request.method === 'PATCH' && examMatch) { + const body = await readJson(request); + const exam = db.exams.find(item => item.id === examMatch[1]); + if (!exam) return sendError(response, 404, '考试不存在'); + if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; + ['name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'].forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], 500); }); + logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); + await writeDb(db); + return sendJson(response, 200, { ok: true, exam }); + } + if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) }); + if (request.method === 'POST' && pathname === '/api/admin/notices') { + const body = await readJson(request); + const title = cleanText(body.title, 120); + const content = cleanText(body.content, 5000); + if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空'); + const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName }; + db.notices.push(notice); + logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title); + await writeDb(db); + return sendJson(response, 201, { ok: true, notice }); + } + const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/); + if (request.method === 'PATCH' && noticeMatch) { + const body = await readJson(request); + const notice = db.notices.find(item => item.id === noticeMatch[1]); + if (!notice) return sendError(response, 404, '通知不存在'); + ['title', 'summary', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); }); + if (body.pinned != null) notice.pinned = Boolean(body.pinned); + if (body.status && ['draft', 'published'].includes(body.status)) { + notice.status = body.status; + if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso(); + } + logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`); + await writeDb(db); + return sendJson(response, 200, { ok: true, notice }); + } + if (request.method === 'GET' && pathname === '/api/admin/results') { + const results = db.results.map(result => { + const registration = db.registrations.find(item => item.id === result.registrationId); + const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); + const exam = db.exams.find(item => item.id === registration?.examId); + const subject = exam?.subjects.find(item => item.id === result.subjectId); + return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name }; + }); + return sendJson(response, 200, { ok: true, results, registrations: db.registrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) }); + } + if (request.method === 'POST' && pathname === '/api/admin/results') { + const body = await readJson(request); + const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved'); + if (!registration) return sendError(response, 404, '已通过的报名记录不存在'); + const exam = db.exams.find(item => item.id === registration.examId); + if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目'); + const score = Number(body.score); + if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间'); + let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId); + if (!result) { + result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId }; + db.results.push(result); + } + Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null }); + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const subject = exam.subjects.find(item => item.id === body.subjectId); + logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`); + await writeDb(db); + return sendJson(response, 200, { ok: true, result }); + } + return sendError(response, 404, '管理功能接口不存在'); +} + +async function serveStatic(response, pathname) { + const requestPath = pathname === '/' ? '/index.html' : pathname; + if (!staticFiles.has(requestPath)) return false; + const filePath = normalize(join(root, requestPath.replace(/^\/+/, ''))); + const body = await readFile(filePath); + response.writeHead(200, { 'Content-Type': mimeTypes[extname(filePath)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); + response.end(body); + return true; +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); + const pathname = decodeURIComponent(url.pathname); + try { + if (pathname.startsWith('/api/public/')) { + const handled = await handlePublic(pathname, response); + if (handled !== false) return; + } + const authHandled = await handleAuth(request, response, pathname); + if (authHandled !== false) return; + const candidateHandled = await handleCandidate(request, response, pathname); + if (candidateHandled !== false) return; + const adminHandled = await handleAdmin(request, response, pathname); + if (adminHandled !== false) return; + if (await serveStatic(response, pathname)) return; + sendError(response, 404, '页面或接口不存在'); + } catch (error) { + console.error(error); + sendError(response, error.status || 500, error.status ? error.message : '服务器处理请求时发生错误'); + } +}); + +await ensureDatabase(); +server.listen(port, '127.0.0.1', () => { + console.log(`衡准考试信息管理系统:http://127.0.0.1:${port}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..88b8b22 --- /dev/null +++ b/styles.css @@ -0,0 +1,166 @@ +:root { + --ink: #14203d; + --navy: #132451; + --navy-soft: #21386e; + --blue: #315fba; + --red: #c8473d; + --jade: #268466; + --amber: #d79624; + --paper: #f5f7fb; + --white: #ffffff; + --line: #e1e6ef; + --muted: #788197; + --shadow: 0 18px 50px rgba(25, 41, 81, .09); + --radius: 16px; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; color: var(--ink); background: var(--paper); font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; -webkit-font-smoothing: antialiased; } +button, input, select, textarea { color: inherit; font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +a { color: inherit; text-decoration: none; } +svg { width: 1.25em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(49, 95, 186, .25); outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: .5; } +.boot-screen { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--navy); } +.boot-screen strong { font-family: "STKaiti", "KaiTi", serif; font-size: 32px; letter-spacing: 8px; } +.boot-screen span:last-child { color: var(--muted); font-size: 12px; } + +.brand { display: inline-flex; align-items: center; gap: 12px; } +.brand > span:last-child { display: grid; } +.brand strong { font-family: "STKaiti", "KaiTi", serif; font-size: 25px; letter-spacing: 5px; line-height: 1; } +.brand small { margin-top: 5px; color: #7e8db5; font-family: Consolas, monospace; font-size: 8px; letter-spacing: 1.8px; } +.brand-symbol { position: relative; width: 36px; height: 36px; display: inline-grid; place-items: center; flex: 0 0 auto; border: 1px solid currentColor; border-radius: 50%; transform: rotate(-9deg); } +.brand-symbol i { position: absolute; width: 21px; height: 2px; border-radius: 3px; background: currentColor; } +.brand-symbol i:first-child { width: 13px; transform: translateY(-6px); } +.brand-symbol i:last-child { width: 9px; transform: translateY(6px); } +.solid-button, .ghost-button, .text-button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 17px; border-radius: 9px; font-size: 12px; font-weight: 700; transition: transform .18s ease, border-color .18s ease, background .18s ease; } +.solid-button { border: 1px solid var(--navy); color: #fff; background: var(--navy); box-shadow: 0 8px 20px rgba(19, 36, 81, .16); } +.solid-button:hover:not(:disabled) { transform: translateY(-1px); background: var(--navy-soft); } +.solid-button svg, .ghost-button svg { width: 15px; } +.ghost-button { border: 1px solid var(--line); color: #525d75; background: #fff; } +.ghost-button:hover { border-color: #b9c1d1; color: var(--navy); } +.text-button { border: 0; color: var(--navy); background: transparent; } +.large { min-height: 48px; padding: 0 21px; } +.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; } +.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; } +.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } +.status-open, .status-published, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; } +.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; } +.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; } +.status-draft { color: #6d7589; background: #eef0f4; } +.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; } + +/* Public site */ +.public-header { position: sticky; top: 0; z-index: 30; height: 76px; border-bottom: 1px solid rgba(225, 230, 239, .85); background: rgba(255, 255, 255, .9); backdrop-filter: blur(18px); } +.public-nav { width: min(1180px, calc(100% - 48px)); height: 100%; display: flex; align-items: center; gap: 34px; margin: auto; } +.public-nav nav { display: flex; gap: 29px; margin-left: 35px; } +.public-nav nav a { position: relative; color: #596278; font-size: 12px; font-weight: 600; } +.public-nav nav a::after { content: ""; position: absolute; left: 0; right: 100%; bottom: -9px; height: 2px; background: var(--red); transition: right .18s; } +.public-nav nav a:hover { color: var(--navy); } +.public-nav nav a:hover::after { right: 0; } +.nav-actions { display: flex; align-items: center; gap: 4px; margin-left: auto; } +.mobile-menu { display: none; width: 39px; height: 39px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: #fff; } +.public-main { overflow: hidden; } +.hero { position: relative; min-height: 660px; display: grid; align-items: center; background-color: #f7f8fb; background-image: linear-gradient(rgba(19,36,81,.025) 1px, transparent 1px), linear-gradient(90deg,rgba(19,36,81,.025) 1px,transparent 1px); background-size: 28px 28px; } +.hero::before { content: ""; position: absolute; width: 600px; height: 600px; right: -340px; top: -250px; border: 1px solid rgba(19,36,81,.07); border-radius: 50%; box-shadow: 0 0 0 80px rgba(19,36,81,.02), 0 0 0 160px rgba(19,36,81,.015); } +.hero-grid { width: min(1180px, calc(100% - 48px)); display: grid; grid-template-columns: minmax(0,1fr) minmax(470px,.88fr); align-items: center; gap: 75px; margin: auto; padding: 70px 0 90px; } +.notice-ticker { width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 9px; margin-bottom: 39px; padding: 6px 10px 6px 6px; border: 1px solid #e0e5ef; border-radius: 7px; background: #fff; box-shadow: 0 6px 20px rgba(22, 38, 77, .04); } +.notice-ticker span { padding: 3px 7px; border-radius: 4px; color: #fff; background: var(--red); font-size: 9px; } +.notice-ticker button { max-width: 330px; overflow: hidden; border: 0; color: #5e687f; background: transparent; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.hero-copy h1 { margin: 15px 0 21px; font-family: "STKaiti", "KaiTi", serif; font-size: clamp(46px, 5vw, 68px); font-weight: 400; line-height: 1.18; letter-spacing: 1px; } +.hero-copy h1 em { color: var(--navy); font-style: normal; } +.hero-copy h1 em::after { content: ""; width: 54px; height: 5px; display: inline-block; margin-left: 14px; border-radius: 5px; background: var(--red); vertical-align: middle; transform: rotate(-3deg); } +.hero-lead { max-width: 540px; margin: 0; color: #697389; font-size: 14px; line-height: 1.9; } +.hero-actions { display: flex; gap: 10px; margin-top: 30px; } +.hero-stats { display: flex; gap: 40px; margin-top: 45px; } +.hero-stats div { display: grid; gap: 3px; } +.hero-stats strong { font-family: Georgia, serif; font-size: 26px; font-weight: 500; } +.hero-stats span { color: #8790a4; font-size: 10px; } +.hero-ticket { position: relative; display: grid; grid-template-columns: 1fr 105px; border-radius: 18px; color: #fff; background: var(--navy); box-shadow: 0 30px 70px rgba(19,36,81,.25); transform: rotate(1.7deg); } +.hero-ticket::before, .hero-ticket::after { content: ""; position: absolute; z-index: 2; right: 93px; width: 24px; height: 24px; border-radius: 50%; background: var(--paper); } +.hero-ticket::before { top: -12px; }.hero-ticket::after { bottom: -12px; } +.ticket-main { position: relative; padding: 31px 31px 28px; overflow: hidden; } +.ticket-main::after { content: "准"; position: absolute; right: -18px; bottom: -66px; color: rgba(255,255,255,.035); font-family: "STKaiti",serif; font-size: 210px; } +.ticket-main header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; } +.ticket-main header .status { color: #f6b1aa; background: rgba(200,71,61,.18); } +.ticket-main header small { color: #95a2c6; font-family: Consolas,monospace; font-size: 9px; } +.ticket-main > p { margin: 37px 0 7px; color: #7383b2; font-family: Consolas,monospace; font-size: 9px; letter-spacing: 1.7px; } +.ticket-main h2 { position: relative; z-index: 1; margin: 0 0 24px; font-family: "STKaiti",serif; font-size: 26px; font-weight: 400; letter-spacing: 1px; } +.ticket-main dl { position: relative; z-index: 1; display: grid; gap: 12px; margin: 0; } +.ticket-main dl div { display: grid; grid-template-columns: 70px 1fr; align-items: baseline; gap: 10px; } +.ticket-main dt { color: #8492ba; font-size: 9px; } +.ticket-main dd { margin: 0; color: #dce2f3; font-size: 11px; } +.subject-chips { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 5px; margin-top: 24px; } +.subject-chips span { padding: 5px 8px; border: 1px solid rgba(255,255,255,.13); border-radius: 5px; color: #bac4e2; font-size: 9px; } +.ticket-stub { position: relative; display: flex; align-items: center; flex-direction: column; justify-content: center; gap: 7px; border-left: 1px dashed rgba(255,255,255,.2); text-align: center; } +.ticket-stub span { color: #8290b9; font-size: 8px; }.ticket-stub strong { font-family: Georgia,serif; font-size: 30px; font-weight: 400; } +.ticket-stub i { width: 1px; height: 30px; background: rgba(255,255,255,.16); } +.ticket-stub button { border: 0; color: #fff; background: transparent; font-size: 10px; writing-mode: vertical-rl; letter-spacing: 2px; } +.content-section { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 90px 0; } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 35px; margin-bottom: 35px; } +.section-heading h2 { margin: 7px 0 0; font-family: "STKaiti",serif; font-size: 35px; font-weight: 400; } +.section-heading > p { max-width: 450px; margin: 0; color: #7d8698; font-size: 12px; line-height: 1.8; text-align: right; } +.notice-layout { display: grid; grid-template-columns: .85fr 1.3fr; gap: 18px; } +.featured-notice { min-height: 310px; display: flex; flex-direction: column; padding: 30px; border-radius: var(--radius); color: #fff; background: var(--navy); box-shadow: var(--shadow); } +.featured-notice > span { width: fit-content; padding: 4px 8px; border-radius: 4px; color: #ffd0cb; background: rgba(200,71,61,.22); font-size: 9px; } +.featured-notice h3 { margin: 28px 0 13px; font-family: "STKaiti",serif; font-size: 25px; font-weight: 400; line-height: 1.5; } +.featured-notice p { margin: 0; color: #aeb9d6; font-size: 11px; line-height: 1.9; } +.featured-notice footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.1); } +.featured-notice time { color: #8593ba; font-size: 9px; }.featured-notice button { display: flex; align-items: center; gap: 6px; border: 0; color: #fff; background: transparent; font-size: 10px; }.featured-notice button svg { width: 14px; } +.notice-list { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; box-shadow: 0 9px 30px rgba(23,39,76,.045); overflow: hidden; } +.notice-row { width: 100%; min-height: 77px; display: grid; grid-template-columns: 75px 1fr 22px; align-items: center; gap: 12px; padding: 12px 20px; border: 0; border-bottom: 1px solid #edf0f5; color: var(--ink); background: #fff; text-align: left; transition: background .15s; } +.notice-row:last-child { border-bottom: 0; }.notice-row:hover { background: #fafbfc; }.notice-row time { color: #8b93a5; font-size: 9px; } +.notice-row > span { min-width: 0; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 9px; }.notice-row em { padding: 3px 5px; border-radius: 4px; color: var(--red); background: #fbe9e7; font-size: 8px; font-style: normal; }.notice-row strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.notice-row small { grid-column: 1/-1; overflow: hidden; color: #8c94a5; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.notice-row > svg { color: #9da4b3; } +.exam-section { padding-top: 40px; } +.public-exam-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 17px; } +.public-exam-card { padding: 24px; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; transition: transform .2s, box-shadow .2s; } +.public-exam-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }.public-exam-card header { display: flex; justify-content: space-between; } +.public-exam-card h3 { margin: 19px 0 7px; font-family: "STKaiti",serif; font-size: 22px; font-weight: 400; }.public-exam-card > p { min-height: 42px; margin: 0; color: #7d8597; font-size: 10px; line-height: 1.8; } +.exam-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }.exam-meta span { display: grid; gap: 4px; padding: 10px; border-radius: 7px; background: #f7f8fb; color: #657087; font-size: 9px; }.exam-meta b { color: #9ba2b0; font-size: 8px; font-weight: 500; } +.public-exam-card footer { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--line); }.public-exam-card footer > span { color: #8b93a4; font-size: 9px; }.public-exam-card footer button { display: flex; align-items: center; gap: 6px; border: 0; color: var(--navy); background: transparent; font-size: 10px; font-weight: 700; }.public-exam-card footer svg { width: 14px; } +.service-flow { padding: 85px max(24px,calc((100% - 1180px)/2)); color: #fff; background: var(--navy); } +.section-heading.light .overline { color: #7182b2; }.section-heading.light h2 { color: #fff; }.section-heading.light > p { color: #95a2c5; } +.flow-track { position: relative; display: grid; grid-template-columns: repeat(5,1fr); gap: 25px; }.flow-track::before { content:""; position:absolute; top:20px; left:20px; right:20px; height:1px; background:rgba(255,255,255,.13); }.flow-track article { position: relative; }.flow-track article span { width: 41px; height: 41px; display: grid; place-items: center; border: 1px solid #5d6e9e; border-radius: 50%; color: #fff; background: var(--navy); font-family: Georgia,serif; font-size: 11px; }.flow-track article:nth-child(3) span { border-color: var(--red); background: var(--red); box-shadow: 0 0 0 7px rgba(200,71,61,.12); }.flow-track h3 { margin: 19px 0 7px; font-size: 12px; }.flow-track p { margin: 0; color: #8e9abe; font-size: 9px; line-height: 1.7; } +.public-footer { display: flex; align-items: flex-end; justify-content: space-between; padding: 38px max(24px,calc((100% - 1180px)/2)); border-top: 1px solid var(--line); background: #fff; }.public-footer > div { display: grid; gap: 13px; }.public-footer p,.public-footer > span { margin:0; color:#858d9e; font-size:9px; } + +/* Authentication */ +.auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; } +.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; } +.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; } +.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; } + +/* Portal shell */ +.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; } +.panel { border:1px solid var(--line); border-radius:var(--radius); background:#fff; box-shadow:0 8px 28px rgba(24,40,79,.045); }.panel-title { min-height:58px; display:flex; align-items:center; justify-content:space-between; padding:0 19px; border-bottom:1px solid var(--line); }.panel-title h2 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.panel-title > span,.panel-title > button { border:0; color:#8c94a5; background:transparent; font-size:9px; }.loading-panel { min-height:360px; display:grid; place-content:center; justify-items:center; gap:12px; color:#858d9e; }.loading-panel i { width:28px; height:28px; border:2px solid #dce1eb; border-top-color:var(--navy); border-radius:50%; animation:spin .7s linear infinite; }.loading-panel span { font-size:10px; }@keyframes spin{to{transform:rotate(360deg)}} + +/* Candidate */ +.candidate-welcome { position:relative; min-height:165px; display:flex; align-items:center; justify-content:space-between; padding:28px 32px; border-radius:var(--radius); color:#fff; background:var(--navy); overflow:hidden; }.candidate-welcome::after { content:""; position:absolute; width:360px; height:360px; right:-220px; top:-160px; border:1px solid rgba(255,255,255,.08); border-radius:50%; box-shadow:0 0 0 55px rgba(255,255,255,.025),0 0 0 110px rgba(255,255,255,.018); }.candidate-welcome span { color:#8998c1; font-size:9px; }.candidate-welcome h2 { margin:6px 0; font-family:"STKaiti"; font-size:26px; font-weight:400; }.candidate-welcome p { margin:0; color:#abb6d4; font-size:10px; }.welcome-seal { z-index:1; width:70px; height:70px; display:grid; place-items:center; border:3px double #eb7770; border-radius:50%; color:#ef8b84; font-family:"STKaiti"; font-size:20px; line-height:1; text-align:center; transform:rotate(-7deg); }.summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:16px 0; }.summary-grid article { display:grid; grid-template-columns:38px 1fr auto; align-items:center; gap:10px; padding:17px; border:1px solid var(--line); border-radius:11px; background:#fff; }.summary-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fc; }.summary-icon svg { width:17px; }.summary-grid article div { display:grid; gap:2px; }.summary-grid article small { color:#8d95a5; font-size:8px; }.summary-grid article strong { font-size:13px; }.summary-grid article > button { border:0; color:var(--blue); background:transparent; font-size:8px; }.candidate-grid { display:grid; grid-template-columns:1.35fr 1fr; gap:16px; }.candidate-progress { display:grid; grid-template-columns:repeat(5,1fr); padding:28px 20px; }.progress-step { position:relative; display:grid; justify-items:center; gap:8px; text-align:center; }.progress-step::before { content:""; position:absolute; top:14px; right:50%; left:-50%; height:2px; background:#e5e9f0; }.progress-step:first-child::before { display:none; }.progress-step i { z-index:1; width:29px; height:29px; display:grid; place-items:center; border:2px solid #dfe3eb; border-radius:50%; color:#9da4b2; background:#fff; font-size:9px; font-style:normal; }.progress-step.done::before,.progress-step.done i { border-color:var(--navy); color:#fff; background:var(--navy); }.progress-step.current i { border-color:var(--red); color:#fff; background:var(--red); box-shadow:0 0 0 5px rgba(200,71,61,.1); }.progress-step div { display:grid; gap:3px; }.progress-step strong { font-size:9px; }.progress-step small { color:#9198a8; font-size:8px; }.compact-notices > button { width:100%; display:grid; grid-template-columns:70px 1fr; gap:8px; padding:15px 18px; border:0; border-bottom:1px solid #edf0f5; color:var(--ink); background:#fff; text-align:left; }.compact-notices > button:last-child { border-bottom:0; }.compact-notices time { color:#9299a9; font-size:8px; }.compact-notices span { overflow:hidden; font-size:9px; text-overflow:ellipsis; white-space:nowrap; } +.form-panel { padding:25px; }.form-section-title { display:flex; align-items:center; gap:12px; margin:3px 0 18px; }.form-section-title:not(:first-child) { margin-top:30px; padding-top:24px; border-top:1px solid var(--line); }.form-section-title > span { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--navy); font-family:Georgia,serif; font-size:9px; }.form-section-title h2 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.form-section-title p { margin:3px 0 0; color:#8d95a5; font-size:8px; }.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:15px; }.profile-form input,.profile-form select { min-height:42px; }.review-note { margin-top:20px; padding:14px; border-radius:8px; font-size:9px; }.review-note.approved { color:#236f56; background:#e9f5f0; }.review-note.rejected { color:#9a3e38; background:#fcebea; }.review-note strong { display:block; margin-bottom:4px; }.review-note p { margin:0; }.form-actions { display:flex; align-items:center; justify-content:space-between; margin-top:25px; padding-top:18px; border-top:1px solid var(--line); }.form-actions p { margin:0; color:#8d95a5; font-size:9px; } +.exam-application-list { display:grid; gap:17px; }.apply-card { border:1px solid var(--line); border-radius:var(--radius); background:#fff; box-shadow:0 8px 28px rgba(24,40,79,.045); overflow:hidden; }.apply-card > header { display:flex; align-items:center; justify-content:space-between; padding:16px 21px; border-bottom:1px solid var(--line); }.apply-card > header > div { display:flex; align-items:center; gap:9px; }.apply-card > header small { color:#8f96a6; font-size:9px; }.apply-card-main { display:grid; grid-template-columns:.8fr 1.2fr; }.apply-copy { padding:26px; border-right:1px solid var(--line); background:#fafbfc; }.apply-copy h2 { margin:0 0 8px; font-family:"STKaiti"; font-size:23px; font-weight:400; }.apply-copy > p { min-height:35px; margin:0; color:#828a9b; font-size:9px; line-height:1.8; }.apply-copy dl { display:grid; gap:12px; margin:23px 0 0; }.apply-copy dl div { display:grid; gap:4px; }.apply-copy dt { color:#9ba2b0; font-size:8px; }.apply-copy dd { margin:0; color:#596379; font-size:9px; }.subject-selector { padding:22px; }.subject-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }.subject-title strong { font-size:11px; }.subject-title span { color:#8e96a7; font-size:8px; }.subject-options { display:grid; grid-template-columns:repeat(2,1fr); gap:7px; }.subject-options label { cursor:pointer; }.subject-options input { position:absolute; opacity:0; }.subject-options label > span { display:grid; grid-template-columns:31px 1fr auto; align-items:center; gap:0 9px; padding:10px; border:1px solid var(--line); border-radius:8px; transition:.15s; }.subject-options label > span > i { grid-row:1/3; width:31px; height:31px; display:grid; place-items:center; border-radius:7px; color:#52658f; background:#e9edf6; font-size:10px; font-style:normal; font-weight:700; }.subject-options b { font-size:9px; }.subject-options small { color:#9299a9; font-size:7px; }.subject-options em { grid-row:1/3; grid-column:3; color:#727c90; font-size:8px; font-style:normal; }.subject-options input:checked + span { border-color:#7f91bd; background:#f2f5fb; box-shadow:inset 0 0 0 1px #7f91bd; }.subject-options input:checked + span > i { color:#fff; background:var(--navy); }.subject-total { display:flex; align-items:center; justify-content:space-between; margin:16px 0 11px; padding-top:12px; border-top:1px solid var(--line); color:#7d8597; font-size:9px; }.subject-total b { color:var(--red); }.subject-total strong { color:var(--ink); font-size:11px; }.subject-selector > .solid-button { width:100%; }.registered-banner { display:flex; align-items:center; gap:8px; margin-top:16px; padding:11px; border-radius:8px; color:#27775b; background:#e7f4ef; font-size:9px; }.registered-banner svg { width:15px; }.registered-banner .status { margin-left:auto; } +.registration-cards { display:grid; gap:15px; }.registration-card { border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.registration-card > header { display:flex; align-items:center; justify-content:space-between; padding:18px 22px; border-bottom:1px solid var(--line); }.registration-card > header h2 { margin:3px 0 0; font-family:"STKaiti"; font-size:19px; font-weight:400; }.registration-info { display:grid; grid-template-columns:.8fr 1.2fr; }.registration-info > dl { display:grid; gap:13px; margin:0; padding:22px; border-right:1px solid var(--line); background:#fafbfc; }.registration-info dl div { display:grid; gap:3px; }.registration-info dt { color:#969dac; font-size:8px; }.registration-info dd { margin:0; color:#5c667b; font-family:Consolas,monospace; font-size:9px; }.selected-subjects { padding:22px; }.selected-subjects > strong { font-size:10px; }.selected-subjects > div { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.selected-subjects span { min-width:105px; display:grid; gap:3px; padding:9px; border:1px solid var(--line); border-radius:7px; font-size:9px; }.selected-subjects small { color:#9299aa; font-size:7px; }.registration-card > footer { display:flex; justify-content:space-between; align-items:center; padding:13px 22px; border-top:1px solid var(--line); }.registration-card > footer p { margin:0; color:#818a9c; font-size:8px; }.admit-list { display:grid; gap:18px; }.admit-ticket { position:relative; display:grid; grid-template-columns:1fr 205px; border-radius:var(--radius); color:#fff; background:var(--navy); box-shadow:var(--shadow); overflow:hidden; }.admit-ticket::before,.admit-ticket::after { content:""; position:absolute; right:193px; width:24px; height:24px; border-radius:50%; background:var(--paper); }.admit-ticket::before { top:-12px; }.admit-ticket::after { bottom:-12px; }.admit-main { padding:27px 30px; }.admit-main header { display:flex; justify-content:space-between; }.admit-main header > span { color:#8291ba; font-family:Consolas,monospace; font-size:9px; }.admit-main h2 { margin:16px 0 19px; font-family:"STKaiti"; font-size:23px; font-weight:400; }.admit-number { display:flex; align-items:flex-end; gap:14px; margin-bottom:18px; }.admit-number small { color:#8290b7; font-size:8px; }.admit-number strong { font-family:Consolas,monospace; font-size:18px; letter-spacing:1px; }.admit-main dl { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin:0; }.admit-main dl div { display:grid; gap:4px; }.admit-main dt { color:#7988b2; font-size:8px; }.admit-main dd { margin:0; color:#d6ddef; font-size:9px; }.admit-stub { display:flex; align-items:center; flex-direction:column; justify-content:center; gap:15px; padding:24px; border-left:1px dashed rgba(255,255,255,.18); text-align:center; }.admit-stub > span { color:#6f7fac; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.admit-stub i { width:36px; height:1px; background:rgba(255,255,255,.15); }.admit-stub .solid-button { border-color:#fff; color:var(--navy); background:#fff; box-shadow:none; }.admit-stub small { color:#7e8db6; font-size:7px; }.result-groups { display:grid; gap:16px; }.result-panel { overflow:hidden; }.result-panel > header { display:flex; justify-content:space-between; align-items:center; padding:20px 23px; border-bottom:1px solid var(--line); }.result-panel > header span { color:#7f899f; font-family:Consolas,monospace; font-size:8px; }.result-panel > header h2 { margin:4px 0 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.result-panel > header small { color:#8c94a4; font-size:8px; }.score-grid { display:grid; grid-template-columns:repeat(5,1fr); }.score-grid article { position:relative; min-height:145px; display:grid; place-content:center; justify-items:center; padding:18px; border-right:1px solid var(--line); }.score-grid article:last-child { border-right:0; }.score-grid span { color:#596379; font-size:10px; }.score-grid strong { margin:8px 0 1px; font-family:Georgia,serif; font-size:37px; font-weight:400; }.score-grid em { position:absolute; top:15px; right:15px; padding:3px 6px; border-radius:4px; color:#27795c; background:#e2f3ec; font-size:8px; font-style:normal; }.score-grid small { color:#a0a6b3; font-size:7px; }.result-panel > footer { display:flex; justify-content:space-between; padding:13px 22px; border-top:1px solid var(--line); color:#878f9f; font-size:8px; }.result-panel > footer p { margin:0; }.notice-center { overflow:hidden; }.notice-center-list > button { width:100%; min-height:83px; display:grid; grid-template-columns:55px 1fr auto 20px; align-items:center; gap:15px; padding:13px 20px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.notice-center-list > button:last-child { border-bottom:0; }.notice-center-list > button:hover { background:#fafbfc; }.notice-center time { display:grid; justify-items:center; color:var(--navy); }.notice-center time strong { font-family:Georgia,serif; font-size:22px; font-weight:400; }.notice-center time span { color:#8e96a7; font-size:8px; }.notice-center button > span { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:4px 8px; }.notice-center em { padding:3px 5px; border-radius:4px; color:var(--red); background:#fbe9e7; font-size:7px; font-style:normal; }.notice-center button > span strong { font-size:10px; }.notice-center button > span small { grid-column:1/-1; overflow:hidden; color:#8b93a4; font-size:8px; text-overflow:ellipsis; white-space:nowrap; }.notice-center button > i { color:var(--red); font-size:7px; font-style:normal; } +.empty-panel { min-height:370px; display:grid; place-content:center; justify-items:center; padding:40px; text-align:center; }.empty-panel > span { width:56px; height:56px; display:grid; place-items:center; border-radius:50%; color:#687da9; background:#edf1f8; }.empty-panel > span svg { width:24px; }.empty-panel h2 { margin:17px 0 7px; font-family:"STKaiti"; font-size:22px; font-weight:400; }.empty-panel p { max-width:440px; margin:0 0 18px; color:#858d9e; font-size:9px; line-height:1.8; } + +/* Admin */ +.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; } +.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; } +.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; } + +/* Modals and feedback */ +.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; }.notice-content p { margin:0 0 13px; color:#525d73; font-size:11px; line-height:2; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; } +.panel-title h2 { flex:0 0 auto; white-space:nowrap; } +.audit-feed > .panel-title { min-height:58px; display:flex; grid-template-columns:none; align-items:center; justify-content:space-between; gap:0; padding:0 19px; } +@keyframes fadeIn{from{opacity:0}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}} + +@media (max-width: 1120px) { + .hero-grid { grid-template-columns:1fr 430px; gap:40px; }.hero-copy h1 { font-size:52px; }.summary-grid,.admin-metrics { grid-template-columns:1fr 1fr; }.candidate-grid,.admin-dashboard-grid { grid-template-columns:1fr; }.score-grid { grid-template-columns:repeat(3,1fr); }.score-grid article:nth-child(3) { border-right:0; }.score-grid article:nth-child(n+4) { border-top:1px solid var(--line); }.results-admin-grid { grid-template-columns:1fr; } +} +@media (max-width: 850px) { + .public-nav { width:calc(100% - 28px); }.public-nav nav { position:absolute; top:75px; left:14px; right:14px; display:none; flex-direction:column; gap:0; margin:0; padding:9px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow); }.public-nav nav.open { display:flex; }.public-nav nav a { padding:12px; }.mobile-menu { display:grid; }.nav-actions > .text-button { display:none; }.hero { min-height:auto; }.hero-grid { grid-template-columns:1fr; gap:55px; padding:60px 0 75px; }.hero-ticket { width:min(540px,100%); }.notice-layout,.public-exam-grid { grid-template-columns:1fr; }.flow-track { grid-template-columns:1fr 1fr; gap:30px; }.flow-track::before { display:none; }.auth-page { grid-template-columns:1fr; }.auth-story { min-height:360px; padding:35px 35px 40px; }.auth-story .overline { margin-top:55px; }.auth-story h1 { font-size:42px; }.auth-quote { display:none; }.portal-sidebar { transform:translateX(-100%); transition:.25s; box-shadow:18px 0 55px rgba(12,22,48,.2); }.portal-sidebar.open { transform:none; }.portal-brand > button { display:block; }.portal-main { margin-left:0; }.sidebar-toggle { display:grid; }.portal-topbar { padding:0 18px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { display:none; }.portal-content { padding:22px 18px; }.apply-card-main,.registration-info { grid-template-columns:1fr; }.apply-copy,.registration-info > dl { border-right:0; border-bottom:1px solid var(--line); }.admit-ticket { grid-template-columns:1fr 170px; }.admit-ticket::before,.admit-ticket::after { right:158px; }.admin-exam-grid { grid-template-columns:1fr; } +} +@media (max-width: 620px) { + .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } +} +@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; transition-duration:.01ms !important; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs new file mode 100644 index 0000000..112a340 --- /dev/null +++ b/tests/system.test.mjs @@ -0,0 +1,176 @@ +import { spawn } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import assert from 'node:assert/strict'; + +const root = resolve(process.cwd()); +const port = 4182; +const baseUrl = `http://127.0.0.1:${port}`; +const testDb = resolve(root, 'data', 'test-db.json'); +await rm(testDb, { force: true }); + +const server = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { ...process.env, PORT: String(port), EXAM_DB_PATH: testDb }, + stdio: ['ignore', 'pipe', 'pipe'] +}); + +let serverError = ''; +server.stderr.on('data', chunk => { serverError += chunk.toString(); }); + +async function waitForServer() { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/api/public/home`); + if (response.ok) return; + } catch {} + await new Promise(resolveWait => setTimeout(resolveWait, 100)); + } + throw new Error(`服务器未能启动:${serverError}`); +} + +function createClient() { + let cookie = ''; + return { + async request(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + const type = response.headers.get('content-type') || ''; + const data = type.includes('application/json') ? await response.json() : await response.text(); + return { response, data }; + } + }; +} + +const admin = createClient(); +const candidate = createClient(); +const anonymous = createClient(); + +try { + await waitForServer(); + + const publicHome = await anonymous.request('/api/public/home'); + assert.equal(publicHome.response.status, 200); + assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); + assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); + + const register = await candidate.request('/api/auth/register', { + method: 'POST', + body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', school: '海州市测试中学', grade: '高三(1)班' } + }); + assert.equal(register.response.status, 201, '考生应可自主注册'); + + const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: 'test_candidate', password: 'Test12345!' } }); + assert.equal(loginCandidate.data.user.role, 'candidate'); + + const updateProfile = await candidate.request('/api/candidate/profile', { + method: 'PUT', + body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', school: '海州市测试中学', grade: '高三(1)班', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' } + }); + assert.equal(updateProfile.response.status, 200, '考生应可自主维护完整资料'); + assert.equal(updateProfile.data.profile.status, 'pending', '资料修改后应重新进入审核'); + const refreshedSession = await candidate.request('/api/auth/me'); + assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步'); + + const candidateCannotAdmin = await candidate.request('/api/admin/dashboard'); + assert.equal(candidateCannotAdmin.response.status, 403, '考生不得访问管理接口'); + + const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } }); + assert.equal(loginAdmin.data.user.role, 'admin'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + const createExam = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '系统全流程测试考试', code: 'EX-TEST-FLOW', description: '自动化测试创建的多科目考试', + registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(), + examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(), + admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), + location: '测试考点', status: 'published', subjects: ['语文', '数学', '外语'] + } + }); + assert.equal(createExam.response.status, 201); + assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); + const exam = createExam.data.exam; + + const beforeApproval = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id] } }); + assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试'); + + const candidates = await admin.request('/api/admin/candidates'); + const profile = candidates.data.candidates.find(item => item.username === 'test_candidate'); + assert.ok(profile, '管理员应能看到新注册考生'); + assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料'); + const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '自动化测试审核通过' } }); + assert.equal(approveProfile.data.profile.status, 'approved'); + + const submitRegistration = await candidate.request('/api/candidate/registrations', { + method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id, exam.subjects[2].id] } + }); + assert.equal(submitRegistration.response.status, 201); + assert.equal(submitRegistration.data.registration.subjectIds.length, 2, '考生应可自主选择多个科目'); + const registrationId = submitRegistration.data.registration.id; + + const adminRegistrations = await admin.request('/api/admin/registrations'); + const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId); + assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列'); + + const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格核验通过' } }); + assert.equal(approveRegistration.data.registration.status, 'approved'); + + const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); + assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号'); + + const candidateRegistrations = await candidate.request('/api/candidate/registrations'); + const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId); + assert.ok(candidateRegistration.admitCard, '考生端应看到已生成准考证'); + + const downloadAdmit = await candidate.request(`/api/candidate/registrations/${registrationId}/admit-card`); + assert.equal(downloadAdmit.response.status, 200, '规定时间内应可下载准考证'); + assert.match(downloadAdmit.data, /测试考生新名/); + assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/); + + const futureExamResponse = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '准考证窗口限制测试', code: 'EX-TEST-WINDOW', description: '验证下载时间限制', + registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(), + examStart: new Date(now + 96 * hour).toISOString(), examEnd: new Date(now + 100 * hour).toISOString(), + admitDownloadStart: new Date(now + 48 * hour).toISOString(), admitDownloadEnd: new Date(now + 95 * hour).toISOString(), + location: '测试考点', status: 'published', subjects: ['综合能力'] + } + }); + const futureExam = futureExamResponse.data.exam; + const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } }); + const futureRegistrationId = futureRegistration.data.registration.id; + await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); + await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); + const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); + assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载'); + + const publishNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '系统测试成绩发布通知', summary: '测试通知将同步到首页', content: '系统测试考试成绩现已发布。', category: '成绩通知', pinned: true, status: 'published' } }); + assert.equal(publishNotice.response.status, 201); + const refreshedHome = await anonymous.request('/api/public/home'); + assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见'); + + const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } }); + assert.equal(publishResult.response.status, 200); + const results = await candidate.request('/api/candidate/results'); + assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); + + console.log('✓ 公开首页与通知读取'); + console.log('✓ 考生自主注册、完整资料维护与管理员审核'); + console.log('✓ 多科目考试创建与考生自主选科报名'); + console.log('✓ 报名审核、准考证生成、开放期下载与窗口限制'); + console.log('✓ 成绩录入、发布与考生查询'); + console.log('✓ 候选人与管理员角色权限隔离'); +} finally { + server.kill('SIGTERM'); + await new Promise(resolveWait => server.once('exit', resolveWait)); + await rm(testDb, { force: true }); +}