diff --git a/app/app.vue b/app/app.vue index 54d4288..3bad1d2 100644 --- a/app/app.vue +++ b/app/app.vue @@ -3,7 +3,7 @@ * 全站入口:公共购买页可在未登录时使用独立布局,其余页面沿用路由指定的布局。 * 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。 */ -import { computed, onMounted, onBeforeUnmount } from "vue"; +import { computed, onBeforeUnmount } from "vue"; import { useRouter } from "#app"; import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components"; import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue"; @@ -32,8 +32,8 @@ const routeSessionKey = computed(() => ? route.value.path : auth.token || "anonymous", ); -// 主题监听由入口统一注册和释放,避免每次页面导航重复绑定系统主题事件。 -onMounted(theme.init); +// 在子组件首次渲染前同步主题;入口唯一持有监听,卸载时释放,不随导航重复注册。 +theme.init(); onBeforeUnmount(theme.dispose); diff --git a/app/lib/useTheme.js b/app/lib/useTheme.js index a6a706d..1d99c72 100644 --- a/app/lib/useTheme.js +++ b/app/lib/useTheme.js @@ -1,14 +1,7 @@ /** 浅色/深色/跟随系统三态主题,使用共享 Nuxt 状态与 html.app-dark 驱动组件和原生控件。 */ import { computed } from "vue"; import { useState } from "#app"; -// 存储不可用或值未知时安全回落 auto,不让浏览器隐私限制阻断页面初始化。 -function n(e) { - try { - let t = (e ?? globalThis.localStorage)?.getItem(`theme`); - if (t === `light` || t === `dark`) return t; - } catch {} - return `auto`; -} +import { applyTheme } from "~/utils/theme-appearance.js"; var r = [`light`, `dark`, `auto`], i = { light: `浅色`, @@ -22,7 +15,7 @@ var r = [`light`, `dark`, `auto`], }, o = `theme`; function s() { - let s = useState(`theme-mode`, () => n()), + let s = useState(`theme-mode`, () => applyTheme().mode), c = useState( `theme-dark`, () => @@ -30,17 +23,8 @@ function s() { document.documentElement.classList.contains(`app-dark`), ), l = () => window.matchMedia(`(prefers-color-scheme: dark)`); - // 仅 auto 读取系统偏好;显式 light/dark 不受系统变化影响。 - function u() { - c.value = s.value === `dark` || (s.value === `auto` && l().matches); - } - function d() { - let e = document.documentElement; - (e.classList.toggle(`app-dark`, c.value), - (e.style.colorScheme = c.value ? `dark` : `light`)); - } function f() { - (u(), d()); + c.value = applyTheme(s.value).dark; } let p = null, m = () => { @@ -51,7 +35,12 @@ function s() { (p?.removeEventListener(`change`, m), (p = null)); } function g() { - (h(), (s.value = n()), f(), (p = l()), p.addEventListener(`change`, m)); + h(); + const appearance = applyTheme(); + s.value = appearance.mode; + c.value = appearance.dark; + p = l(); + p.addEventListener(`change`, m); } // 调用方传入已知模式;持久化失败不影响当前页面切换。 function _(e) { diff --git a/app/spa-loading-template.html b/app/spa-loading-template.html new file mode 100644 index 0000000..2ca118d --- /dev/null +++ b/app/spa-loading-template.html @@ -0,0 +1,62 @@ + +
+ 破碎工坊云 + +

正在加载控制台

+ +
+ diff --git a/app/utils/theme-appearance.js b/app/utils/theme-appearance.js new file mode 100644 index 0000000..984e7bc --- /dev/null +++ b/app/utils/theme-appearance.js @@ -0,0 +1,15 @@ +/** 同一套规则用于首屏内联启动和运行时切换,避免两次绘制使用不同主题。 */ +export function applyTheme(mode) { + if (mode === undefined) { + try { + mode = localStorage.getItem("theme"); + } catch {} + } + if (mode !== "light" && mode !== "dark") mode = "auto"; + const dark = + mode === "dark" || + (mode === "auto" && matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.classList.toggle("app-dark", dark); + document.documentElement.style.colorScheme = dark ? "dark" : "light"; + return { mode, dark }; +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 79b1833..1de8113 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,4 +1,6 @@ /** 应用构建入口;所有运行时业务地址来自公开配置,不绑定历史服务器目录。 */ +import { applyTheme } from "./app/utils/theme-appearance.js"; + export default defineNuxtConfig({ compatibilityDate: "2026-10-25", // 页面依赖浏览器存储、DOM 及远控能力,使用客户端 SPA,而非服务端渲染页面。 @@ -39,7 +41,24 @@ export default defineNuxtConfig({ head: { htmlAttrs: { lang: "zh-CN" }, title: "破碎工坊云 - 会员中心", + script: [ + { + key: "console-theme", + // 普通内联脚本在业务模块和首次绘制前恢复外观,不等待网络或组件挂载。 + innerHTML: `(${applyTheme.toString()})();`, + tagPriority: "critical", + }, + ], link: [ + // 只预载全站共享的小资源;中文分片字体继续按实际字符加载,避免抢占首屏带宽。 + { rel: "preload", as: "image", href: "/images/brand/logo.png" }, + { + rel: "preload", + as: "font", + type: "font/woff2", + crossorigin: "anonymous", + href: "/icons/primeicons.Cw7jw2u3.woff2", + }, // 字体和站点图标随 public/ 分发,离线测试和私有部署不依赖外部 CDN。 { rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Regular.css" }, { rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Semibold.css" }, diff --git a/tests/README.md b/tests/README.md index 4b0996f..c61c5f1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -39,6 +39,7 @@ CHROME_PATH=/absolute/path/to/chrome npm test - `review-purchase.e2e.test.mjs`:镜像订单未付复用、取消后重建、已付授权延迟不重复收费;级联候选与默认、结算、购物车恢复一致。 - `review-fields.e2e.test.mjs`:常见 ASCII 规则本地拦截、PCRE 专有规则交后端校验;实例随机密码实际提交及安全源失效、无偏采样边界。 - `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。 +- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。 - `review-ui-foundation.e2e.test.mjs`:OpenVue/Deni 的手机导航焦点约束、Escape/离页关闭、1024/1025 断点与桌面恢复、手机表格字段标签和可操作的窄屏编辑弹窗。 - `review-style-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点;首页用户 ID 复制按钮在各宽度下不拆行(该问题只在 Firefox 出现,场景额外启动 Playwright Firefox,需先执行 `npx playwright install firefox`)。 - `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。 diff --git a/tests/review-startup.e2e.test.mjs b/tests/review-startup.e2e.test.mjs new file mode 100644 index 0000000..17b468d --- /dev/null +++ b/tests/review-startup.e2e.test.mjs @@ -0,0 +1,286 @@ +/** 首屏回归:延迟真实入口脚本时仍显示稳定占位,并在业务代码执行前恢复用户主题。 */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +for (const preference of [ + { stored: "dark", system: "light", dark: true }, + { stored: "light", system: "dark", dark: false }, + { stored: "auto", system: "dark", dark: true }, + { stored: "auto", system: "light", dark: false }, + { stored: "unknown", system: "dark", dark: true }, +]) { + test( + `cold refresh keeps a styled loading shell before scripts: ${preference.stored}/${preference.system}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ colorScheme: preference.system }); + await page.addInitScript((mode) => { + localStorage.setItem("theme", mode); + }, preference.stored); + let releaseScripts; + const scriptsHeld = new Promise((resolve) => { + releaseScripts = resolve; + }); + let markRequested; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + await page.route(`${appOrigin}/_nuxt/*.js`, async (route) => { + markRequested(); + await scriptsHeld; + await route.continue(); + }); + let before; + try { + await page.goto(`${appOrigin}/login`, { waitUntil: "commit" }); + await requested; + await page.getByRole("status", { name: "页面加载中" }).waitFor(); + before = await page.evaluate(() => ({ + dark: document.documentElement.classList.contains("app-dark"), + colorScheme: document.documentElement.style.colorScheme, + rootChildren: document.getElementById("__nuxt").children.length, + viewport: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + loaderBackground: getComputedStyle( + document.getElementById("console-startup"), + ).backgroundColor, + loaderPosition: getComputedStyle( + document.getElementById("console-startup"), + ).position, + })); + assert.equal(before.dark, preference.dark); + assert.equal(before.colorScheme, preference.dark ? "dark" : "light"); + assert.equal(before.rootChildren, 0); + assert.equal(before.scrollWidth, before.viewport); + assert.equal(before.loaderPosition, "fixed"); + assert.equal( + before.loaderBackground, + preference.dark ? "rgb(24, 24, 27)" : "rgb(248, 250, 252)", + ); + } finally { + releaseScripts(); + } + await page.locator(".blank-layout").waitFor(); + await page.getByRole("status", { name: "页面加载中" }).waitFor({ + state: "detached", + }); + assert.equal( + await page + .locator("html") + .evaluate((html) => html.classList.contains("app-dark")), + preference.dark, + ); + await page.getByText("密码登录", { exact: true }).click(); + await page.locator("input[type=password]").waitFor(); + return { + themeBeforeScripts: before.dark, + styledShellBeforeScripts: true, + loadingShellRemoved: true, + loginInteractive: true, + }; + }), + ); +} + +for (const boundary of ["bootstrap", "route-css"]) { + test( + `mobile refresh keeps the shell until ${boundary} is ready`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); + await page.addInitScript(() => { + window.startupFrames = []; + // 在浏览器实际绘制边界采样占位退场后的前三帧,不向产品代码加入测试状态。 + function sample() { + const shell = document.querySelector(".auth-shell"); + if (shell && !document.getElementById("__nuxt-loader")) { + const style = getComputedStyle(shell); + window.startupFrames.push({ + boxSizing: style.boxSizing, + display: style.display, + width: shell.getBoundingClientRect().width, + viewport: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + dark: document.documentElement.classList.contains("app-dark"), + }); + } + if (window.startupFrames.length < 3) requestAnimationFrame(sample); + } + requestAnimationFrame(sample); + }); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let markRequested; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + const url = + boundary === "bootstrap" + ? `${backendOrigin}/console/v1/common` + : `${appOrigin}/_nuxt/login.*.css`; + await page.route(url, async (route) => { + markRequested(); + await held; + await route.continue(); + }); + try { + await page.goto(`${appOrigin}/login`, { waitUntil: "commit" }); + await requested; + await page.getByRole("status", { name: "页面加载中" }).waitFor(); + assert.equal( + await page + .locator(".startup-spinner") + .evaluate((element) => getComputedStyle(element).animationName), + "none", + ); + } finally { + release(); + } + await page.locator(".auth-shell").waitFor(); + await page.getByRole("status", { name: "页面加载中" }).waitFor({ + state: "detached", + }); + await page.waitForFunction(() => window.startupFrames.length === 3); + const frames = await page.evaluate(() => window.startupFrames); + for (const appearance of frames) { + assert.equal(appearance.boxSizing, "border-box"); + assert.equal(appearance.display, "grid"); + assert.equal(appearance.dark, true); + assert.ok(appearance.width <= appearance.viewport); + assert.equal(appearance.scrollWidth, appearance.viewport); + } + assert.deepEqual(frames[0], frames[2]); + return { + boundary, + shellDuringWait: true, + shellRemoved: true, + firstVisibleFrames: frames, + }; + }), + ); +} + +test( + "delayed font downloads do not keep the startup shell over a usable page", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let markRequested; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + await page.route(/\.woff2(?:\?|$)/, async (route) => { + markRequested(); + await held; + await route.continue(); + }); + try { + await page.goto(`${appOrigin}/login`, { waitUntil: "commit" }); + await requested; + await page.getByLabel("密码", { exact: true }).waitFor(); + await page.getByRole("status", { name: "页面加载中" }).waitFor({ + state: "detached", + }); + await page + .getByLabel("账号", { exact: true }) + .fill("audit@example.invalid"); + assert.equal( + await page.getByLabel("账号", { exact: true }).inputValue(), + "audit@example.invalid", + ); + } finally { + release(); + } + return { interactiveBeforeFonts: true, loadingShellRemoved: true }; + }), +); + +test( + "bootstrap failure releases the shell and allows configuration recovery", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let configurationAvailable = false; + await page.route(`${backendOrigin}/console/v1/common`, async (route) => { + if (configurationAvailable) return route.continue(); + return route.fulfill({ + json: { status: 500, msg: "站点配置暂不可用", data: {} }, + }); + }); + await page.goto(`${appOrigin}/login`); + const retry = page.getByRole("button", { name: "重新加载", exact: true }); + await retry.waitFor(); + assert.equal(await page.locator("#__nuxt-loader").count(), 0); + configurationAvailable = true; + await retry.click(); + await retry.waitFor({ state: "detached" }); + await page.getByText("验证码登录", { exact: true }).click(); + await page + .getByRole("button", { name: "获取验证码", exact: true }) + .waitFor(); + return { failureDoesNotTrapShell: true, configurationRecovered: true }; + }), +); + +test( + "member deep-link refresh restores layout without a second loading shell on navigation", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: "light" }); + await login(page); + await page.goto(`${appOrigin}/security/ssh-keys`); + await page + .getByRole("heading", { name: "SSH 密钥", exact: true }) + .waitFor(); + await page.reload(); + await page + .getByRole("heading", { name: "SSH 密钥", exact: true }) + .waitFor(); + await page + .getByRole("status", { name: "页面加载中" }) + .waitFor({ state: "detached" }); + const layout = await page.locator(".deni-layout").evaluate((element) => ({ + boxSizing: getComputedStyle(element).boxSizing, + viewport: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + assert.equal(layout.boxSizing, "border-box"); + assert.equal(layout.scrollWidth, layout.viewport); + await page.getByRole("button", { name: "打开菜单", exact: true }).click(); + const navigation = page.getByRole("navigation", { + name: "会员中心导航", + exact: true, + }); + await navigation + .getByRole("button", { name: "安全", exact: true }) + .click(); + await navigation + .getByRole("link", { name: "安全组", exact: true }) + .click(); + await page + .getByRole("heading", { name: "安全组", exact: true }) + .waitFor(); + assert.equal(await page.locator("#__nuxt-loader").count(), 0); + return { memberRefreshStyled: true, loadingShellOnlyAtStartup: true }; + }), +);