Commit 9774d780 by DaiJiezhang

fix: stabilize refresh authentication redirect

parent 52071f07
...@@ -4,9 +4,18 @@ ...@@ -4,9 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学有为资产管理台</title> <title>学有为资产管理台</title>
<style>
html, body, #app { min-height: 100%; margin: 0; }
.app-boot-splash { display: grid; min-height: 100vh; place-items: center; overflow: hidden; background: radial-gradient(circle at 20% 15%, rgba(92, 118, 173, .2), transparent 32%), radial-gradient(circle at 82% 78%, rgba(98, 77, 148, .18), transparent 28%), #0d1524; }
.app-boot-splash__mark { width: 72px; height: 72px; border: 1px solid rgba(226, 232, 240, .16); border-radius: 50%; box-shadow: 0 0 0 12px rgba(226, 232, 240, .025), 0 0 72px rgba(130, 150, 210, .16); }
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<main class="app-boot-splash" aria-label="正在打开应用">
<div class="app-boot-splash__mark" aria-hidden="true"></div>
</main>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
import { authState, signOut } from './modules/auth/auth-store.js'; import { authState, signOut } from './modules/auth/auth-store.js';
/** 代码作用(白话):提供已登录后台外壳,让登录路由不显示侧栏。关联文件:router/index.js、LoginView.js。关联逻辑(调用链/数据流):当前 Hash 路由 -> 登录页或菜单壳 -> RouterView。 */ /** 代码作用(白话):提供已登录后台外壳,让登录路由不显示侧栏。关联文件:router/index.js、LoginView.js。关联逻辑(调用链/数据流):当前 Hash 路由 -> 登录页或菜单壳 -> RouterView。 */
export default { setup() { /** 代码作用(白话):退出当前会话并无论后端 Cookie 清理是否报错都跳回登录路由。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):退出按钮 -> POST logout -> 清空前端身份 -> finally 跳转 /login。 */ async function logout() { try { await signOut(); } finally { window.location.hash = '#/login'; } } /** 代码作用(白话):判断当前用户能否看见某个受控菜单,编辑权限自然包含只读菜单;关联文件:router/index.js、PagePermissionService.java。关联逻辑(调用链/数据流):/api/auth/me 权限 -> 侧栏菜单 -> 路由守卫。 */ function canVisit(page) { return authState.user?.pagePermissions?.[page] && authState.user.pagePermissions[page] !== 'NONE'; } /** 代码作用(白话):判断当前用户是否可以使用设置下的账号与权限功能;关联文件:UserPermissionView.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):角色码 -> 菜单展示 -> 后端二次授权。 */ function isAdministrator() { return ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user?.roleCode); } return { authState, logout, canVisit, isAdministrator }; }, template: `<RouterView v-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>` }; export default {
/** 代码作用(白话):把认证状态、退出动作和菜单权限判断提供给页面模板;关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):路由守卫恢复身份 -> authState.ready/user -> 此组件决定加载占位、登录页或后台外壳。 */
setup() {
/** 代码作用(白话):退出当前会话并无论后端 Cookie 清理是否报错都跳回登录路由。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):退出按钮 -> POST logout -> 清空前端身份 -> finally 跳转 /login。 */
async function logout() {
try {
await signOut();
} finally {
window.location.hash = '#/login';
}
}
/** 代码作用(白话):判断当前用户能否看见某个受控菜单,编辑权限自然包含只读菜单。关联文件:router/index.js、PagePermissionService.java。关联逻辑(调用链/数据流):/api/auth/me 权限 -> 侧栏菜单 -> 路由守卫。 */
function canVisit(page) {
return authState.user?.pagePermissions?.[page] && authState.user.pagePermissions[page] !== 'NONE';
}
/** 代码作用(白话):判断当前用户是否可以使用设置下的账号与权限功能。关联文件:UserPermissionView.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):角色码 -> 菜单展示 -> 后端二次授权。 */
function isAdministrator() {
return ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user?.roleCode);
}
/** 代码作用(白话):在会话检查遇到网络问题时重新加载当前地址,让路由守卫重新请求当前用户而不先跳登录页。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):网络错误提示按钮 -> retryConnection() -> 浏览器刷新当前 Hash 地址 -> bootstrapAuth() -> 后台、登录页或再次提示。 */
function retryConnection() {
window.location.reload();
}
return { authState, logout, canVisit, isAdministrator, retryConnection };
},
template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>`
};
...@@ -4,10 +4,13 @@ import 'element-plus/dist/index.css'; ...@@ -4,10 +4,13 @@ import 'element-plus/dist/index.css';
import App from './App.js'; import App from './App.js';
import router from './router/index.js'; import router from './router/index.js';
import './styles/app.css'; import './styles/app.css';
import './styles/auth-state.css';
/** /**
* 代码作用(白话):创建重构后的唯一 Vue 应用入口,把路由和界面组件挂到页面根节点。 * 代码作用(白话):创建重构后的唯一 Vue 应用入口,把路由和界面组件挂到页面根节点。
* 关联文件:frontend/index.html、frontend/src/App.js、frontend/src/router/index.js。 * 关联文件:frontend/index.html、frontend/src/App.js、frontend/src/router/index.js。
* 关联逻辑(调用链/数据流):index.html -> main.js -> createApp() -> router -> 静态参考页或重构占位页。 * 关联逻辑(调用链/数据流):index.html -> main.js -> createApp() -> router -> 静态参考页或重构占位页。
*/ */
createApp(App).use(router).use(ElementPlus).mount('#app'); const application = createApp(App).use(router).use(ElementPlus);
await router.isReady();
application.mount('#app');
...@@ -2,12 +2,14 @@ ...@@ -2,12 +2,14 @@
function csrfHeader() { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/); return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}; } function csrfHeader() { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/); return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}; }
/** Plain purpose: detect multipart form data so the browser can create its required boundary header. Related files: device-api-client.js, DeviceAssetController.java. Flow: device form -> FormData -> shared request -> browser multipart header -> controller binding. */ /** Plain purpose: detect multipart form data so the browser can create its required boundary header. Related files: device-api-client.js, DeviceAssetController.java. Flow: device form -> FormData -> shared request -> browser multipart header -> controller binding. */
function isFormDataBody(body) { return typeof FormData !== 'undefined' && body instanceof FormData; } function isFormDataBody(body) { return typeof FormData !== 'undefined' && body instanceof FormData; }
/** 代码作用(白话):把请求失败原因和 HTTP 状态一起交给调用方,便于首次会话检查区分未登录和网络异常。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):fetch 失败响应 -> createRequestError() -> bootstrapAuth() -> 登录重定向或网络提示。 */
function createRequestError(message, status) { const error = new Error(message); error.status = status; return error; }
/** Plain purpose: notify the application that the server rejected the current session and it must return to login. Related files: auth-store.js, router/index.js. Flow: protected API 401 -> browser event -> cleared auth state -> login route. */ /** Plain purpose: notify the application that the server rejected the current session and it must return to login. Related files: auth-store.js, router/index.js. Flow: protected API 401 -> browser event -> cleared auth state -> login route. */
function notifyExpiredSession() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('xyw-auth-expired')); } function notifyExpiredSession() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('xyw-auth-expired')); }
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */ /** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login') return csrfHeader(); const existing = csrfHeader(); if (Object.keys(existing).length) return existing; const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); const refreshed = csrfHeader(); if (!Object.keys(refreshed).length) throw new Error('安全校验初始化失败'); return refreshed; } export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login') return csrfHeader(); const existing = csrfHeader(); if (Object.keys(existing).length) return existing; const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); const refreshed = csrfHeader(); if (!Object.keys(refreshed).length) throw new Error('安全校验初始化失败'); return refreshed; }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */ /** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && path !== '/api/auth/login') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '请求失败'); return payload.data; } export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && path !== '/api/auth/login' && path !== '/api/auth/me') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw createRequestError(payload.message || '请求失败', response.ok ? payload.code : response.status); return payload.data; }
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */ /** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); } export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); }
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */ /** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
......
import { reactive } from 'vue/dist/vue.esm-bundler.js'; import { reactive } from 'vue/dist/vue.esm-bundler.js';
import { getCurrentUser, login, logout } from './auth-api-client.js'; import { getCurrentUser, login, logout } from './auth-api-client.js';
/** 代码作用(白话):保存内存中的安全用户资料,页面刷新后只从后端 Cookie 恢复而不写入浏览器存储。关联文件:router/index.js、App.js。关联逻辑(调用链/数据流):Auth API -> authState -> 路由守卫/菜单。 */ /** 代码作用(白话):保存内存中的安全用户资料,页面刷新后只从后端 Cookie 恢复而不写入浏览器存储。关联文件:router/index.js、App.js。关联逻辑(调用链/数据流):Auth API -> authState -> 路由守卫/菜单。 */
export const authState = reactive({ ready: false, user: null }); export const authState = reactive({ ready: false, user: null, connectionError: false });
/** Plain purpose: remove a browser identity whose server-side session is no longer valid and return to the public login route. Related files: auth-api-client.js, router/index.js. Flow: protected API 401 -> browser event -> authState clear -> login route. */ /** Plain purpose: remove a browser identity whose server-side session is no longer valid and return to the public login route. Related files: auth-api-client.js, router/index.js. Flow: protected API 401 -> browser event -> authState clear -> login route. */
export function expireSession() { authState.user = null; authState.ready = true; if (typeof window !== 'undefined') window.location.hash = '#/login'; } export function expireSession() { authState.user = null; authState.connectionError = false; authState.ready = true; if (typeof window !== 'undefined') window.location.hash = '#/login'; }
/** Plain purpose: receive a shared API-session-expired event without making auth-api-client and auth-store import each other. Related files: auth-api-client.js, auth-store.js. Flow: request helper -> browser event -> expireSession -> login route. */ /** Plain purpose: receive a shared API-session-expired event without making auth-api-client and auth-store import each other. Related files: auth-api-client.js, auth-store.js. Flow: request helper -> browser event -> expireSession -> login route. */
if (typeof window !== 'undefined') window.addEventListener('xyw-auth-expired', expireSession); if (typeof window !== 'undefined') window.addEventListener('xyw-auth-expired', expireSession);
/** 代码作用(白话):向后端恢复一次当前会话,失败时清除内存身份。关联文件:auth-api-client.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> authState。 */ /** 代码作用(白话):向后端恢复一次当前会话,并区分未登录和网络无法连接两种结果。关联文件:auth-api-client.js、router/index.js、App.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> 401 交给路由跳登录,网络错误写入 connectionError -> App 显示重试页。 */
export async function bootstrapAuth() { try { authState.user = await getCurrentUser(); } catch (_) { authState.user = null; } finally { authState.ready = true; } return authState.user; } export async function bootstrapAuth() { const timeoutMs = typeof window !== 'undefined' && window.__XYW_AUTH_CHECK_TIMEOUT_MS ? window.__XYW_AUTH_CHECK_TIMEOUT_MS : 8000; let timeoutId; authState.connectionError = false; try { authState.user = await Promise.race([getCurrentUser(), new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error('SESSION_CHECK_TIMEOUT')), timeoutMs); })]); } catch (error) { authState.user = null; authState.connectionError = error.status !== 401; } finally { if (timeoutId) window.clearTimeout(timeoutId); authState.ready = true; } return authState.user; }
/** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */ /** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */
export async function signIn(credentials) { authState.user = await login(credentials); authState.ready = true; return authState.user; } export async function signIn(credentials) { authState.connectionError = false; authState.user = await login(credentials); authState.ready = true; return authState.user; }
/** 代码作用(白话):尝试登出并无论结果都清空本地身份。关联文件:App.js。关联逻辑(调用链/数据流):登出 -> API -> authState 清空 -> 登录路由。 */ /** 代码作用(白话):尝试登出并无论结果都清空本地身份。关联文件:App.js。关联逻辑(调用链/数据流):登出 -> API -> authState 清空 -> 登录路由。 */
export async function signOut() { try { await logout(); } finally { authState.user = null; authState.ready = true; } } export async function signOut() { try { await logout(); } finally { authState.user = null; authState.connectionError = false; authState.ready = true; } }
...@@ -39,6 +39,7 @@ const router = createRouter({ ...@@ -39,6 +39,7 @@ const router = createRouter({
/** 代码作用(白话):在进入业务路由前恢复 Cookie 会话,未登录用户一律进入新建的登录路由。关联文件:auth-store.js、LoginView.js。关联逻辑(调用链/数据流):Hash 地址 -> 路由守卫 -> /api/auth/me -> 业务页或 /login。 */ /** 代码作用(白话):在进入业务路由前恢复 Cookie 会话,未登录用户一律进入新建的登录路由。关联文件:auth-store.js、LoginView.js。关联逻辑(调用链/数据流):Hash 地址 -> 路由守卫 -> /api/auth/me -> 业务页或 /login。 */
/** 代码作用(白话):在已登录后继续按逐页权限和管理角色校验目标路由,避免只隐藏菜单就能手输地址绕过;关联文件:App.js、PagePermissionService.java。关联逻辑(调用链/数据流):路由地址 -> authState 有效权限 -> 放行或返回总览。 */ /** 代码作用(白话):在已登录后继续按逐页权限和管理角色校验目标路由,避免只隐藏菜单就能手输地址绕过;关联文件:App.js、PagePermissionService.java。关联逻辑(调用链/数据流):路由地址 -> authState 有效权限 -> 放行或返回总览。 */
router.beforeEach(async to => { if (!authState.ready) await bootstrapAuth(); if (to.meta.public) return authState.user ? '/overview' : true; if (!authState.user) return '/login'; const isAdministrator = ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user.roleCode); if (to.meta.administratorOnly && !isAdministrator) return '/overview'; if (to.meta.page && authState.user.pagePermissions?.[to.meta.page] === 'NONE' && to.path !== '/overview') return '/overview'; return true; }); /** 代码作用(白话):集中决定首轮认证后的目标页面;网络异常时保留当前地址交给错误页,其他结果再执行公开页、登录页和权限判断。关联文件:auth-store.js、auth-api-client.js、App.js。关联逻辑(调用链/数据流):Hash 地址 -> bootstrapAuth() -> connectionError 或 user -> 当前地址、/login、/overview 或业务页。 */
router.beforeEach(async to => { if (!authState.ready) await bootstrapAuth(); if (authState.connectionError) return true; if (to.meta.public) return authState.user ? '/overview' : true; if (!authState.user) return '/login'; const isAdministrator = ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user.roleCode); if (to.meta.administratorOnly && !isAdministrator) return '/overview'; if (to.meta.page && authState.user.pagePermissions?.[to.meta.page] === 'NONE' && to.path !== '/overview') return '/overview'; return true; });
export default router; export default router;
/* 文件用途(白话):集中定义认证结果未确定或网络不可用时的独立页面外观,不与登录密码输入框样式混在一起。 */
/* 代码作用(白话):在极少数应用已挂载但路由仍未完成的时序中,保持中性背景而不向视觉界面显示认证提示文字。关联文件:App.js、main.js、index.html。关联逻辑(调用链/数据流):HTML 启动底图 -> Vue 挂载前等待路由 -> 此样式作为 App 认证分支的视觉兜底 -> 实际路由页面。 */
.auth-loading {
min-height: 100vh;
font-size: 0;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
/* 代码作用(白话):在认证服务不可连接时展示独立的重试页面,避免错误地显示后台或跳转登录页。关联文件:App.js、auth-store.js、router/index.js。关联逻辑(调用链/数据流):会话请求网络失败 -> connectionError=true -> App 网络异常分支 -> 用户重新连接。 */
.auth-network-error {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
color: #e8eefc;
background: radial-gradient(circle at 20% 15%, rgba(92, 118, 173, .2), transparent 32%), radial-gradient(circle at 82% 78%, rgba(98, 77, 148, .18), transparent 28%), #0d1524;
}
.auth-network-error__panel {
width: min(420px, 100%);
padding: 34px;
border: 1px solid rgba(226, 232, 240, .12);
border-radius: 16px;
text-align: center;
background: rgba(18, 28, 48, .72);
box-shadow: 0 22px 60px rgba(0, 0, 0, .22);
}
.auth-network-error__panel h1 { margin: 0; font-size: 22px; }
.auth-network-error__panel p { margin: 14px 0 24px; color: rgba(226, 232, 240, .72); line-height: 1.7; }
.auth-network-error__panel button { min-width: 112px; height: 38px; border: 0; border-radius: 7px; color: #fff; background: #5b7ed0; cursor: pointer; }
...@@ -69,6 +69,54 @@ test('returns to login when the logout endpoint fails', async ({ page }) => { ...@@ -69,6 +69,54 @@ test('returns to login when the logout endpoint fails', async ({ page }) => {
await expect(page).toHaveURL(/#\/login$/); await expect(page).toHaveURL(/#\/login$/);
}); });
/** 代码作用(白话):验证登录页恢复 Cookie 会话期间不抢先渲染后台外壳,避免未登录用户刷新时看见侧栏和业务页面闪屏。关联文件:App.js、router/index.js、auth-store.js。关联逻辑(调用链/数据流):刷新登录路由 -> /api/auth/me 等待 -> authState.ready=false -> 仅加载占位 -> 401 后显示登录页。 */
test('does not render the application shell while the login session check is pending', async ({ page }) => {
let releaseSessionResponse;
let markSessionRequested;
const sessionRequested = new Promise(resolve => { markSessionRequested = resolve; });
const sessionResponseReleased = new Promise(resolve => { releaseSessionResponse = resolve; });
/** 代码作用(白话):把当前用户接口暂时挂起,稳定复现身份尚未恢复的页面时间窗口。关联文件:auth-api-client.js、auth-store.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me 拦截 -> 等待断言 -> 返回未登录响应。 */
await page.route('**/api/auth/me', async route => { markSessionRequested(); await sessionResponseReleased; await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 401, message: '请先登录', data: null }) }); });
const pageLoad = page.goto('/asset/#/login');
await sessionRequested;
await expect(page.locator('.app-shell')).toHaveCount(0);
releaseSessionResponse();
await pageLoad;
await expect(page.locator('.login-page')).toBeVisible();
});
/** 代码作用(白话):验证未登录用户刷新受保护页面时,认证响应尚未返回也不会看见后台侧栏、卡片或登录表单。关联文件:index.html、main.js、router/index.js。关联逻辑(调用链/数据流):浏览器刷新后台 Hash 路由 -> 中性启动底图 -> 路由守卫请求 /api/auth/me -> 401 后进入登录页。 */
test('does not show an admin interface before an unauthenticated protected refresh redirects', async ({ page }) => {
let releaseSessionResponse;
let markSessionRequested;
const sessionRequested = new Promise(resolve => { markSessionRequested = resolve; });
const sessionResponseReleased = new Promise(resolve => { releaseSessionResponse = resolve; });
/** 代码作用(白话):暂停真实路由守卫发出的当前用户请求,制造刷新期间可观察的认证等待窗口。关联文件:auth-api-client.js、auth-store.js、router/index.js。关联逻辑(调用链/数据流):受保护地址 -> 路由守卫 -> /api/auth/me 拦截 -> 中性底图断言 -> 返回未登录响应。 */
await page.route('**/api/auth/me', async route => { markSessionRequested(); await sessionResponseReleased; await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 401, message: '请先登录', data: null }) }); });
const pageLoad = page.goto('/asset/#/overview');
await sessionRequested;
await expect(page.locator('.app-boot-splash')).toBeVisible();
await expect(page.locator('.login-page, .app-shell, .app-boot-shell')).toHaveCount(0);
releaseSessionResponse();
await pageLoad;
await expect(page).toHaveURL(/#\/login$/);
await expect(page.locator('.login-page')).toBeVisible();
});
/** 代码作用(白话):验证后端长时间无响应时,用户仍停留在原受保护地址并能看到网络重试入口,而不会被误跳到登录页。关联文件:auth-store.js、router/index.js、App.js。关联逻辑(调用链/数据流):/api/auth/me 无响应 -> 会话检查超时 -> authState 网络错误状态 -> App 网络提示与重试按钮。 */
test('shows a recoverable timeout state when the session check does not finish', async ({ page }) => {
await page.addInitScript(() => { window.__XYW_AUTH_CHECK_TIMEOUT_MS = 20; });
/** 代码作用(白话):模拟接口不返回的网络故障,触发前端会话检查超时分支。关联文件:auth-api-client.js、auth-store.js。关联逻辑(调用链/数据流):请求拦截 -> 保持挂起 -> 超时状态。 */
await page.route('**/api/auth/me', () => new Promise(() => {}));
await page.goto('/asset/#/overview');
await expect(page).toHaveURL(/#\/overview$/);
await expect(page.locator('.auth-network-error')).toBeVisible();
await expect(page.locator('.login-page, .app-shell')).toHaveCount(0);
});
/** Plain purpose: make the image-backed login page observable so an accidental plain-color fallback is caught. Related files: LoginView.js, black_hole.png. Flow: Vite asset import -> CSS custom property -> login background. */ /** Plain purpose: make the image-backed login page observable so an accidental plain-color fallback is caught. Related files: LoginView.js, black_hole.png. Flow: Vite asset import -> CSS custom property -> login background. */
test('renders the login page with its Vite-resolved black-hole background', async ({ page }) => { test('renders the login page with its Vite-resolved black-hole background', async ({ page }) => {
await mockUnauthenticatedSession(page); await mockUnauthenticatedSession(page);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment