This commit is contained in:
2026-07-20 20:34:21 +08:00 Unverified
parent 9a21c7519c
commit 52497957f3
9 changed files with 59 additions and 5 deletions
+12
View File
@@ -10,6 +10,18 @@ INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123! INITIAL_ADMIN_PASSWORD=Admin123!
INITIAL_ADMIN_DISPLAY_NAME=系统管理员 INITIAL_ADMIN_DISPLAY_NAME=系统管理员
# 公开首页文案与联系方式(修改后重启应用生效)
PUBLIC_SITE_NAME=海州市教育考试中心
PUBLIC_SITE_CODE=HZ-EDU-032
PUBLIC_SITE_PHONE=0518-8602 3158
PUBLIC_SITE_ADDRESS=江苏省连云港市海州区文教路 18
PUBLIC_SITE_EMAIL=
PUBLIC_SITE_HERO_EYEBROW=HAIZHOU EXAMINATION SERVICE
PUBLIC_SITE_HERO_TITLE=一个报名号,
PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。 # MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。
# NODE_ENV=production # NODE_ENV=production
# DATABASE_CLIENT=mysql # DATABASE_CLIENT=mysql
+2
View File
@@ -95,6 +95,8 @@ npm start
应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite``NODE_ENV=production` 默认 `mysql` 应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite``NODE_ENV=production` 默认 `mysql`
公开首页的机构名称、机构代码、电话、地址、邮箱、主标语和页脚提示分别由 `PUBLIC_SITE_NAME``PUBLIC_SITE_CODE``PUBLIC_SITE_PHONE``PUBLIC_SITE_ADDRESS``PUBLIC_SITE_EMAIL``PUBLIC_SITE_HERO_*``PUBLIC_SITE_FOOTER_NOTICE` 配置。修改 `.env` 后需要重启应用;这些配置会覆盖数据库中的演示机构信息,且只通过公开首页接口返回非敏感展示字段。
### 本地 SQLite ### 本地 SQLite
```powershell ```powershell
+15
View File
@@ -22,6 +22,20 @@ if (existsSync(envPath)) loadEnvFile(envPath);
const port = Number(process.env.PORT || 4173); const port = Number(process.env.PORT || 4173);
const host = process.env.HOST || '127.0.0.1'; const host = process.env.HOST || '127.0.0.1';
const publicSiteConfig = Object.freeze({
organization: {
name: process.env.PUBLIC_SITE_NAME || '考试服务平台',
code: process.env.PUBLIC_SITE_CODE || 'EXAM-SERVICE',
phone: process.env.PUBLIC_SITE_PHONE || '',
address: process.env.PUBLIC_SITE_ADDRESS || '',
email: process.env.PUBLIC_SITE_EMAIL || ''
},
heroEyebrow: process.env.PUBLIC_SITE_HERO_EYEBROW || 'EXAMINATION SERVICE',
heroTitle: process.env.PUBLIC_SITE_HERO_TITLE || '一个报名号,',
heroHighlight: process.env.PUBLIC_SITE_HERO_HIGHLIGHT || '贯穿每一次考试。',
heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。',
footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || ''
});
const sessions = new Map(); const sessions = new Map();
const staticFiles = new Set([ const staticFiles = new Set([
'/index.html', '/index.html',
@@ -812,6 +826,7 @@ function admitCardsHtml(db, registrations, title) {
const routeContext = { const routeContext = {
database, database,
readDb, readDb,
publicSiteConfig,
sendJson, sendJson,
sendError, sendError,
readJson, readJson,
+1
View File
@@ -29,6 +29,7 @@ export function createAdminViews(context) {
async function renderAdmin(page) { async function renderAdmin(page) {
if (state.user?.role !== 'admin') return navigate('login'); if (state.user?.role !== 'admin') return navigate('login');
app.classList.add('admin-readable');
const meta = { const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
+1
View File
@@ -65,6 +65,7 @@ export function createCandidateViews(context) {
async function renderCandidate(page) { async function renderCandidate(page) {
if (state.user?.role !== 'candidate') return navigate('login'); if (state.user?.role !== 'candidate') return navigate('login');
app.classList.remove('admin-readable');
if (state.user.mustChangePassword) { if (state.user.mustChangePassword) {
app.innerHTML = onboardingShell('password', passwordOnboardingForm()); app.innerHTML = onboardingShell('password', passwordOnboardingForm());
return; return;
+5 -2
View File
@@ -24,20 +24,22 @@ export function createPublicViews(context) {
} }
function renderHome() { function renderHome() {
app.classList.remove('admin-readable');
const { notices, exams, stats, organization } = state.publicData; const { notices, exams, stats, organization } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0]; const topNotice = notices[0];
app.innerHTML = `${publicHeader()}<main class="public-main"> app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero"> <section class="hero">
<div class="hero-grid"> <div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>一个报名号,<br><em>贯穿每一次考试。</em></h1><p class="hero-lead">使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div> <div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'} ${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div> </div>
</section> </section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section> <section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section> <section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section> <section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`; </main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
} }
function renderHeroTicket(exam) { function renderHeroTicket(exam) {
@@ -54,6 +56,7 @@ export function createPublicViews(context) {
} }
function renderAuth(kind) { function renderAuth(kind) {
app.classList.remove('admin-readable');
const login = kind === 'login'; const login = kind === 'login';
const selfRegistration = state.publicData.selfRegistrationEnabled; const selfRegistration = state.publicData.selfRegistrationEnabled;
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`; app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
+2 -1
View File
@@ -4,6 +4,7 @@ export function createPublicRoutes(context) {
const { const {
database, database,
readDb, readDb,
publicSiteConfig,
sendJson, sendJson,
sendError, sendError,
readJson, readJson,
@@ -54,7 +55,7 @@ export function createPublicRoutes(context) {
if (pathname === '/api/public/home') { 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)).map(noticeForClient); 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)).map(noticeForClient);
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } }); return sendJson(response, 200, { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } });
} }
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/); const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) { if (noticeMatch) {
+16 -1
View File
@@ -230,7 +230,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.service-flow { padding: 85px max(24px,calc((100% - 1180px)/2)); color: #fff; background: var(--navy); } .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; } .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; } .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; } .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; }.public-footer .public-contact-detail { color:#9aa1af; }
/* Authentication */ /* Authentication */
.auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; } .auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; }
@@ -529,6 +529,21 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.candidate-archive-console { display:flex; align-items:center; justify-content:space-between; gap:26px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f3f6fb); }.archive-controls { min-width:420px; display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:9px; }.archive-controls select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.archive-button { background:#9f463f; }.account-archived-row { opacity:.7; background:#f5f6f8; }.candidate-account-actions { display:flex; flex-wrap:wrap; gap:6px; } .candidate-archive-console { display:flex; align-items:center; justify-content:space-between; gap:26px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f3f6fb); }.archive-controls { min-width:420px; display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:9px; }.archive-controls select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.archive-button { background:#9f463f; }.account-archived-row { opacity:.7; background:#f5f6f8; }.candidate-account-actions { display:flex; flex-wrap:wrap; gap:6px; }
.reset-warning { padding:14px; border-left:3px solid var(--red); border-radius:8px; background:#fceceb; }.reset-warning strong { font-size:9px; }.reset-warning p { margin:5px 0 0; color:#875b58; font-size:8px; line-height:1.7; } .reset-warning { padding:14px; border-left:3px solid var(--red); border-radius:8px; background:#fceceb; }.reset-warning strong { font-size:9px; }.reset-warning p { margin:5px 0 0; color:#875b58; font-size:8px; line-height:1.7; }
/* Admin readability: retain the information-dense layout while lifting microcopy to a readable floor. */
.admin-readable .portal-role { font-size:11px; }
.admin-readable .portal-sidebar nav button { font-size:13px; }
.admin-readable .portal-sidebar nav button em { font-size:10px; }
.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:11px; }
.admin-readable .sidebar-help strong { font-size:13px; }
.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:12px; }
.admin-readable .portal-user > span small { font-size:11px; }
.admin-readable .portal-heading > div > p:last-child,.admin-readable .heading-status { font-size:13px; line-height:1.65; }
.admin-readable .portal-content { line-height:1.55; }
.admin-readable .portal-content :where(p,small,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:12px !important; }
.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:12px !important; }
.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:12px !important; }
.admin-readable .portal-content .status,.admin-readable .portal-content .overline { font-size:11px !important; }
@media (max-width: 1000px) { .candidate-archive-console { align-items:flex-start; flex-direction:column; }.archive-controls { width:100%; min-width:0; } } @media (max-width: 1000px) { .candidate-archive-console { align-items:flex-start; flex-direction:column; }.archive-controls { width:100%; min-width:0; } }
@media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; } } @media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; } }
+5 -1
View File
@@ -89,7 +89,7 @@ await seededTestDatabase.close();
const server = spawn(process.execPath, ['server.mjs'], { const server = spawn(process.execPath, ['server.mjs'], {
cwd: root, cwd: root,
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb }, env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
}); });
@@ -207,6 +207,10 @@ try {
assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知');
assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目');
assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭'); assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭');
assert.deepEqual(publicHome.data.organization, { name: '环境变量考试中心', code: 'ENV-TEST', phone: '0518-1234 5678', address: '测试地址 1 号', email: 'service@example.test' }, '公开机构名称及联系方式应从环境变量读取');
assert.equal(publicHome.data.siteCopy.heroTitle, '一次配置,', '首页主标语应从环境变量读取');
assert.equal(publicHome.data.siteCopy.heroHighlight, '统一首页文案。', '首页强调标语应从环境变量读取');
assert.equal(publicHome.data.siteCopy.footerNotice, '环境变量页脚提示', '首页页脚提示应从环境变量读取');
const ckeditorAsset = await anonymous.request('/vendor/ckeditor5/ckeditor5.js'); const ckeditorAsset = await anonymous.request('/vendor/ckeditor5/ckeditor5.js');
assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包'); assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包');
assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件'); assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件');