From 52497957f37b73f75174690fef61eee3955edd36 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 20:34:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 ++++++++++++ README.md | 2 ++ server.mjs | 15 +++++++++++++++ src/client/admin-views.mjs | 1 + src/client/candidate-views.mjs | 1 + src/client/public-views.mjs | 7 +++++-- src/routes/public.routes.mjs | 3 ++- styles.css | 17 ++++++++++++++++- tests/system.test.mjs | 6 +++++- 9 files changed, 59 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index f77d03d..28fc281 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,18 @@ INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=Admin123! 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,并配置以下变量。 # NODE_ENV=production # DATABASE_CLIENT=mysql diff --git a/README.md b/README.md index 5f2cbe9..0874d03 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ npm start 应用启动时会自动读取项目根目录的 `.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 ```powershell diff --git a/server.mjs b/server.mjs index 20c9084..a744577 100644 --- a/server.mjs +++ b/server.mjs @@ -22,6 +22,20 @@ if (existsSync(envPath)) loadEnvFile(envPath); const port = Number(process.env.PORT || 4173); 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 staticFiles = new Set([ '/index.html', @@ -812,6 +826,7 @@ function admitCardsHtml(db, registrations, title) { const routeContext = { database, readDb, + publicSiteConfig, sendJson, sendError, readJson, diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index f636db7..772e6a8 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -29,6 +29,7 @@ export function createAdminViews(context) { async function renderAdmin(page) { if (state.user?.role !== 'admin') return navigate('login'); + app.classList.add('admin-readable'); const meta = { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 9e12c44..8c8fb59 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -65,6 +65,7 @@ export function createCandidateViews(context) { async function renderCandidate(page) { if (state.user?.role !== 'candidate') return navigate('login'); + app.classList.remove('admin-readable'); if (state.user.mustChangePassword) { app.innerHTML = onboardingShell('password', passwordOnboardingForm()); return; diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 7697b0c..2f02b9b 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -24,20 +24,22 @@ export function createPublicViews(context) { } function renderHome() { + app.classList.remove('admin-readable'); const { notices, exams, stats, organization } = state.publicData; + const siteCopy = state.publicData.siteCopy || {}; const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; const topNotice = notices[0]; app.innerHTML = `${publicHeader()}
-
最新

HAIZHOU EXAMINATION SERVICE

一个报名号,
贯穿每一次考试。

使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。

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

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

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

NOTICE BOARD

通知公告

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

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

OPEN EXAMINATIONS

考试报名

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

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

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
-
`; + `; } function renderHeroTicket(exam) { @@ -54,6 +56,7 @@ export function createPublicViews(context) { } function renderAuth(kind) { + app.classList.remove('admin-readable'); const login = kind === 'login'; const selfRegistration = state.publicData.selfRegistrationEnabled; app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}

报名号就是考生账户,不因考试、科目或年度报名而改变。

首次登录顺序

修改初始密码 → 补全个人信息 → 等待资料审核。

${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}

${login ? '报名号登录' : '自主申请报名号'}

${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}

${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}
`; diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index d58d249..1e3429e 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -4,6 +4,7 @@ export function createPublicRoutes(context) { const { database, readDb, + publicSiteConfig, sendJson, sendError, readJson, @@ -54,7 +55,7 @@ export function createPublicRoutes(context) { 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 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\/([^/]+)$/); if (noticeMatch) { diff --git a/styles.css b/styles.css index 9c71c23..8da52e7 100644 --- a/styles.css +++ b/styles.css @@ -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); } .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; } +.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 */ .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; } .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: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 968981c..55e5c25 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -89,7 +89,7 @@ await seededTestDatabase.close(); const server = spawn(process.execPath, ['server.mjs'], { 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'] }); @@ -207,6 +207,10 @@ try { assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); 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'); assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包'); assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件');