修复报错页面

This commit is contained in:
2026-07-21 17:08:03 +08:00 Unverified
parent fe5fcc9dc2
commit 5477a261f8
10 changed files with 91 additions and 9 deletions
+17 -2
View File
@@ -63,10 +63,23 @@ function emptyState(title, description, route, action) {
} }
function renderError(error) { function renderError(error) {
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`; if (error?.status === 401) return requireLogin();
console.error('Page failed to render', error);
const message = error?.status ? error.message : '请求未能完成,请稍后重试或返回首页。';
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(message)}</p><div class="fatal-error-actions"><button class="ghost-button" data-route="home">返回首页</button><button class="solid-button" data-action="retry">重试</button></div></section>`;
} }
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; function requireLogin() {
state.user = null;
state.profile = null;
state.permissions = [];
state.scopeLabel = '';
state.pageData = null;
state.authNotice = '登录状态已失效,请重新登录。';
navigate('login');
}
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState };
const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext); const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
@@ -82,6 +95,7 @@ async function renderRoute() {
window.scrollTo({ top: 0, behavior: 'instant' }); window.scrollTo({ top: 0, behavior: 'instant' });
const route = location.hash.slice(1) || 'home'; const route = location.hash.slice(1) || 'home';
const [section, page = 'dashboard'] = route.split('/'); const [section, page = 'dashboard'] = route.split('/');
if (section !== 'login') state.authNotice = '';
if (section === 'home') renderHome(); if (section === 'home') renderHome();
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); } else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); } else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
@@ -240,6 +254,7 @@ async function refreshSession() {
async function finishLogin(data) { async function finishLogin(data) {
state.user = data.user; state.user = data.user;
state.authNotice = '';
await refreshSession(); await refreshSession();
closeModal(); closeModal();
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`); toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
"test": "node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs", "test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs", "test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs", "reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs", "seed-test-data": "node scripts/import-test-data.mjs",
+2 -1
View File
@@ -21,6 +21,7 @@ export function createAdminViews(context) {
icons, icons,
api, api,
renderError, renderError,
requireLogin,
emptyState, emptyState,
brand, brand,
portalShell, portalShell,
@@ -30,7 +31,7 @@ export function createAdminViews(context) {
} = context; } = context;
async function renderAdmin(page) { async function renderAdmin(page) {
if (state.user?.role !== 'admin') return navigate('login'); if (state.user?.role !== 'admin') return requireLogin();
app.classList.add('admin-readable'); app.classList.add('admin-readable');
const meta = { const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
+2 -2
View File
@@ -1,5 +1,5 @@
export function createAdmissionViews(context) { export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context; const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']]; const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
function shell(page, content, title, description) { function shell(page, content, title, description) {
@@ -7,7 +7,7 @@ export function createAdmissionViews(context) {
} }
async function renderAdmission(page) { async function renderAdmission(page) {
if (state.user?.role !== 'admission_school') return navigate('login'); if (state.user?.role !== 'admission_school') return requireLogin();
if (!nav.some(item => item[0] === page)) page = 'dashboard'; if (!nav.some(item => item[0] === page)) page = 'dashboard';
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] }; const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]); app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
+5 -1
View File
@@ -8,6 +8,10 @@ export async function api(path, options = {}) {
}); });
const type = response.headers.get('content-type') || ''; const type = response.headers.get('content-type') || '';
const data = type.includes('application/json') ? await response.json() : await response.text(); const data = type.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试'); if (!response.ok) {
const error = new Error(data?.message || '操作未完成,请稍后重试');
error.status = response.status;
throw error;
}
return data; return data;
} }
+2 -1
View File
@@ -15,6 +15,7 @@ export function createCandidateViews(context) {
icons, icons,
api, api,
renderError, renderError,
requireLogin,
emptyState, emptyState,
brand brand
} = context; } = context;
@@ -73,7 +74,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 requireLogin();
app.classList.remove('admin-readable'); app.classList.remove('admin-readable');
if (state.user.mustChangePassword) { if (state.user.mustChangePassword) {
app.innerHTML = onboardingShell('password', passwordOnboardingForm()); app.innerHTML = onboardingShell('password', passwordOnboardingForm());
+2 -1
View File
@@ -96,7 +96,8 @@ export function createPublicViews(context) {
app.classList.remove('admin-readable'); 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>`; const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
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>${authNotice}${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>`;
} }
function loginForm() { function loginForm() {
+1
View File
@@ -7,6 +7,7 @@ export const state = {
noticePage: 1, noticePage: 1,
permissions: [], permissions: [],
scopeLabel: '', scopeLabel: '',
authNotice: '',
pageData: null, pageData: null,
resultExamFilter: '', resultExamFilter: '',
resultSubjectFilter: '', resultSubjectFilter: '',
+2
View File
@@ -237,6 +237,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.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 { 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-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; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,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; } .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; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,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; }
.auth-session-notice { display:grid; gap:4px; margin:-12px 0 18px; padding:12px 14px; border-left:3px solid var(--red); border-radius:7px; color:#814b47; background:#fceceb; }.auth-session-notice strong { font-size:10px; }.auth-session-notice span { font-size:9px; line-height:1.7; }
/* Portal shell */ /* 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; } .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; }
@@ -268,6 +269,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
/* Modals and feedback */ /* 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; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.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; } .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; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.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; }
.fatal-error-actions { display:flex; gap:9px; }
.modal-card:has(.exam-config-form) { width:min(980px,100%); } .modal-card:has(.exam-config-form) { width:min(980px,100%); }
.modal-card:has(.notice-editor-form) { width:min(820px,100%); } .modal-card:has(.notice-editor-form) { width:min(820px,100%); }
.notice-editor-field { display:grid; gap:7px; } .notice-editor-field { display:grid; gap:7px; }
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { createAdminViews } from '../src/client/admin-views.mjs';
import { createAdmissionViews } from '../src/client/admission-views.mjs';
import { api } from '../src/client/api.mjs';
import { createCandidateViews } from '../src/client/candidate-views.mjs';
import { createPublicViews } from '../src/client/public-views.mjs';
function protectedViewContext() {
let loginRequests = 0;
const context = {
state: { user: null },
app: { classList: { add() {}, remove() {} } },
requireLogin() { loginRequests += 1; }
};
return { context, loginRequests: () => loginRequests };
}
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
const fixture = protectedViewContext();
const views = createView(fixture.context);
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
await render('dashboard');
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
}
{
const app = { classList: { remove() {} }, innerHTML: '' };
const state = {
user: null,
authNotice: '登录状态已失效,请重新登录。',
publicData: { selfRegistrationEnabled: false }
};
const { renderAuth } = createPublicViews({
state,
app,
h: value => String(value ?? ''),
icons: { arrow: '', menu: '' }
});
renderAuth('login');
assert.match(app.innerHTML, /需要重新登录/);
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
}
{
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
try {
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
} finally {
globalThis.fetch = originalFetch;
}
}
console.log('客户端登录失效处理测试通过');