From 41d6137716b864fd43e6875a128aa3f24f53ccf7 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 01:47:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E5=87=BA=E5=90=8E?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E7=BC=93=E5=AD=98=E6=81=A2=E5=A4=8D=E6=97=A7?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=B9=B6=E9=98=BB=E6=AD=A2=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E4=BC=9A=E8=AF=9D=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.js | 53 +++++++++++++++----- frontend/src/App.test.js | 77 +++++++++++++++++++++++++++++- frontend/src/test-support/mount.js | 4 ++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index 5b24c8f..521d59e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,4 +1,4 @@ -import { onMounted, ref } from "vue"; +import { onBeforeUnmount, onMounted, ref } from "vue"; export function useAppShell() { const setupState = ref(null); @@ -8,9 +8,13 @@ export function useAppShell() { const error = ref(""); const routePath = window.location.pathname.replace(/\/+$/, "") || "/"; const standalonePaths = ["/login", "/password-reset", "/reset"]; + let bootstrapVersion = 0; async function bootstrap() { + const version = ++bootstrapVersion; bootstrapping.value = true; + authenticated.value = null; + isAdmin.value = null; error.value = ""; try { let response = await fetch("/api/v1/bootstrap/status", { cache: "no-store" }); @@ -37,36 +41,39 @@ export function useAppShell() { if (!settings.ok) throw new Error("请使用服务器 bootstrap-url 文件中的完整一次性地址访问,或在原浏览器中继续初始化。"); result = await settings.json(); } + if (version !== bootstrapVersion) return; + const session = result.initialized + ? await checkAuth() + : { authenticated: false, isAdmin: false }; + if (version !== bootstrapVersion) return; setupState.value = result; - if (result.initialized) { - await checkAuth(); - } else { - authenticated.value = false; - isAdmin.value = false; - } + authenticated.value = session.authenticated; + isAdmin.value = session.isAdmin; } catch (err) { + if (version !== bootstrapVersion) return; setupState.value = null; authenticated.value = null; isAdmin.value = null; error.value = err.message || "无法连接核心服务,请先启动 scert serve"; } finally { - bootstrapping.value = false; + if (version === bootstrapVersion) bootstrapping.value = false; } } async function checkAuth() { const response = await fetch("/api/v1/auth/session", { credentials: "include", + cache: "no-store", }); if (response.status === 401) { - authenticated.value = false; - isAdmin.value = false; - return; + return { authenticated: false, isAdmin: false }; } const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "读取登录状态失败"); - authenticated.value = result.authenticated === true; - isAdmin.value = result.is_admin === true || ["super_admin", "admin"].includes(result.role); + return { + authenticated: result.authenticated === true, + isAdmin: result.is_admin === true || ["super_admin", "admin"].includes(result.role), + }; } function handleInitialized(result) { @@ -91,14 +98,34 @@ export function useAppShell() { isAdmin.value = false; } + function handlePageHide() { + // 历史缓存不保留已验证权限,旧请求也不能恢复离开前的登录状态。 + bootstrapVersion += 1; + authenticated.value = null; + isAdmin.value = null; + bootstrapping.value = true; + } + + function handlePageShow(event) { + if (event.persisted) bootstrap(); + } + onMounted(() => { if (standalonePaths.includes(routePath)) { bootstrapping.value = false; return; } + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("pageshow", handlePageShow); bootstrap(); }); + onBeforeUnmount(() => { + bootstrapVersion += 1; + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("pageshow", handlePageShow); + }); + return { setupState, authenticated, diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js index d3f88bc..199aed5 100644 --- a/frontend/src/App.test.js +++ b/frontend/src/App.test.js @@ -1,7 +1,82 @@ import assert from "node:assert/strict"; import test from "node:test"; import { useAppShell } from "./App.js"; -import { mountComposable } from "./test-support/mount.js"; +import { deferred, mountComposable } from "./test-support/mount.js"; + +function dispatchPageEvent(type, persisted = true) { + const event = new Event(type); + Object.defineProperty(event, "persisted", { value: persisted }); + window.dispatchEvent(event); +} + +test("历史缓存恢复先隐藏旧控制台,再按真实会话显示登录页", async (t) => { + let active = true; + const sessionOptions = []; + const { state } = await mountComposable(t, useAppShell, async (path, options) => { + if (path.endsWith("/status")) return Response.json({ initialized: true }); + sessionOptions.push(options); + return active ? Response.json({ authenticated: true, is_admin: true }) : new Response(null, { status: 401 }); + }); + assert.equal(state.authenticated.value, true); + dispatchPageEvent("pagehide"); + assert.equal(state.authenticated.value, null); + assert.equal(state.bootstrapping.value, true); + active = false; + dispatchPageEvent("pageshow"); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.authenticated.value, false); + assert.equal(state.isAdmin.value, false); + assert.equal(state.bootstrapping.value, false); + assert.equal(sessionOptions.length, 2); + assert.ok(sessionOptions.every((options) => options.cache === "no-store" && options.credentials === "include")); +}); + +test("非缓存页面显示不重复检查,卸载后移除恢复监听", async (t) => { + let calls = 0; + const { unmount } = await mountComposable(t, useAppShell, async (path) => { + calls += 1; + return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true }); + }); + assert.equal(calls, 2); + dispatchPageEvent("pageshow", false); + assert.equal(calls, 2); + unmount(); + dispatchPageEvent("pageshow"); + assert.equal(calls, 2); +}); + +test("恢复期间网络失败不恢复旧权限或业务界面", async (t) => { + let failed = false; + const { state } = await mountComposable(t, useAppShell, async (path) => { + if (failed) throw new Error("连接失败"); + return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true, is_admin: true }); + }); + failed = true; + dispatchPageEvent("pageshow"); + assert.equal(state.authenticated.value, null); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.setupState.value, null); + assert.equal(state.isAdmin.value, null); + assert.equal(state.error.value, "连接失败"); +}); + +test("离开页面前的在途会话响应不能覆盖恢复后的未登录状态", async (t) => { + const pending = deferred(); + let sessionCalls = 0; + const { state } = await mountComposable(t, useAppShell, async (path) => { + if (path.endsWith("/status")) return Response.json({ initialized: true }); + sessionCalls += 1; + return sessionCalls === 1 ? pending.promise : new Response(null, { status: 401 }); + }); + dispatchPageEvent("pagehide"); + dispatchPageEvent("pageshow"); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.authenticated.value, false); + pending.resolve(Response.json({ authenticated: true, is_admin: true })); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.authenticated.value, false); + assert.equal(state.isAdmin.value, false); +}); test("初始化令牌兑换前清除地址片段,后续请求只用同源会话", async (t) => { const calls = []; diff --git a/frontend/src/test-support/mount.js b/frontend/src/test-support/mount.js index 14c2e6d..6ecc0b7 100644 --- a/frontend/src/test-support/mount.js +++ b/frontend/src/test-support/mount.js @@ -28,12 +28,16 @@ export function verifiedResponse(token = "test-token") { // 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。 export async function mountComposable(t, factory, fetcher) { + const windowEvents = new EventTarget(); const intervals = new Map(); let nextIntervalID = 1; let currentTime = Date.now(); const originals = new Map(); for (const [key, value] of Object.entries({ window: { + addEventListener: windowEvents.addEventListener.bind(windowEvents), + removeEventListener: windowEvents.removeEventListener.bind(windowEvents), + dispatchEvent: windowEvents.dispatchEvent.bind(windowEvents), setInterval(callback) { const intervalID = nextIntervalID; nextIntervalID += 1;