修复首屏:增加预加载占位并提前恢复主题

This commit is contained in:
cieanyau committed 2026-11-07 05:47:34 +08:00
1 parent 83b7484558
commit 30cbfd3a9c
7 files changed
+395 -23

No files matched your search

+3 -3
View File
@@ -3,7 +3,7 @@
* 全站入口:公共购买页可在未登录时使用独立布局,其余页面沿用路由指定的布局。 * 全站入口:公共购买页可在未登录时使用独立布局,其余页面沿用路由指定的布局。
* 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。 * 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。
*/ */
import { computed, onMounted, onBeforeUnmount } from "vue"; import { computed, onBeforeUnmount } from "vue";
import { useRouter } from "#app"; import { useRouter } from "#app";
import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components"; import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components";
import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue"; import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue";
@@ -32,8 +32,8 @@ const routeSessionKey = computed(() =>
? route.value.path ? route.value.path
: auth.token || "anonymous", : auth.token || "anonymous",
); );
// 主题监听由入口统一注册和释放,避免每次页面导航重复绑定系统主题事件。 // 在子组件首次渲染前同步主题;入口唯一持有监听,卸载时释放,不随导航重复注册。
onMounted(theme.init); theme.init();
onBeforeUnmount(theme.dispose); onBeforeUnmount(theme.dispose);
</script> </script>
+9 -20
View File
@@ -1,14 +1,7 @@
/** 浅色/深色/跟随系统三态主题,使用共享 Nuxt 状态与 html.app-dark 驱动组件和原生控件。 */ /** 浅色/深色/跟随系统三态主题,使用共享 Nuxt 状态与 html.app-dark 驱动组件和原生控件。 */
import { computed } from "vue"; import { computed } from "vue";
import { useState } from "#app"; import { useState } from "#app";
// 存储不可用或值未知时安全回落 auto,不让浏览器隐私限制阻断页面初始化。 import { applyTheme } from "~/utils/theme-appearance.js";
function n(e) {
try {
let t = (e ?? globalThis.localStorage)?.getItem(`theme`);
if (t === `light` || t === `dark`) return t;
} catch {}
return `auto`;
}
var r = [`light`, `dark`, `auto`], var r = [`light`, `dark`, `auto`],
i = { i = {
light: `浅色`, light: `浅色`,
@@ -22,7 +15,7 @@ var r = [`light`, `dark`, `auto`],
}, },
o = `theme`; o = `theme`;
function s() { function s() {
let s = useState(`theme-mode`, () => n()), let s = useState(`theme-mode`, () => applyTheme().mode),
c = useState( c = useState(
`theme-dark`, `theme-dark`,
() => () =>
@@ -30,17 +23,8 @@ function s() {
document.documentElement.classList.contains(`app-dark`), document.documentElement.classList.contains(`app-dark`),
), ),
l = () => window.matchMedia(`(prefers-color-scheme: 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() { function f() {
(u(), d()); c.value = applyTheme(s.value).dark;
} }
let p = null, let p = null,
m = () => { m = () => {
@@ -51,7 +35,12 @@ function s() {
(p?.removeEventListener(`change`, m), (p = null)); (p?.removeEventListener(`change`, m), (p = null));
} }
function g() { 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) { function _(e) {
+62
View File
@@ -0,0 +1,62 @@
<!-- 业务脚本未就绪时使用独立样式;Nuxt 在首个页面解析完成后自动移除此模板。 -->
<div
id="console-startup"
role="status"
aria-label="页面加载中"
aria-live="polite"
>
<img src="/images/brand/logo.png" alt="破碎工坊云" width="168" height="36" />
<span class="startup-spinner" aria-hidden="true"></span>
<p>正在加载控制台</p>
<noscript>请启用 JavaScript 后刷新页面。</noscript>
</div>
<style>
#console-startup {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
box-sizing: border-box;
padding: 24px;
background: #f8fafc;
color: #334155;
font:
14px/1.5 system-ui,
sans-serif;
}
.app-dark #console-startup {
background: #18181b;
color: #e4e4e7;
}
#console-startup img {
width: 168px;
height: 36px;
object-fit: contain;
}
#console-startup p {
margin: 0;
}
#console-startup .startup-spinner {
width: 28px;
height: 28px;
box-sizing: border-box;
border: 3px solid #94a3b833;
border-top-color: #2563eb;
border-radius: 50%;
animation: startup-spin 0.8s linear infinite;
}
@keyframes startup-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
#console-startup .startup-spinner {
animation: none;
}
}
</style>
+15
View File
@@ -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 };
}
+19
View File
@@ -1,4 +1,6 @@
/** 应用构建入口;所有运行时业务地址来自公开配置,不绑定历史服务器目录。 */ /** 应用构建入口;所有运行时业务地址来自公开配置,不绑定历史服务器目录。 */
import { applyTheme } from "./app/utils/theme-appearance.js";
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2026-10-25", compatibilityDate: "2026-10-25",
// 页面依赖浏览器存储、DOM 及远控能力,使用客户端 SPA,而非服务端渲染页面。 // 页面依赖浏览器存储、DOM 及远控能力,使用客户端 SPA,而非服务端渲染页面。
@@ -39,7 +41,24 @@ export default defineNuxtConfig({
head: { head: {
htmlAttrs: { lang: "zh-CN" }, htmlAttrs: { lang: "zh-CN" },
title: "破碎工坊云 - 会员中心", title: "破碎工坊云 - 会员中心",
script: [
{
key: "console-theme",
// 普通内联脚本在业务模块和首次绘制前恢复外观,不等待网络或组件挂载。
innerHTML: `(${applyTheme.toString()})();`,
tagPriority: "critical",
},
],
link: [ 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。 // 字体和站点图标随 public/ 分发,离线测试和私有部署不依赖外部 CDN。
{ rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Regular.css" }, { rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Regular.css" },
{ rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Semibold.css" }, { rel: "stylesheet", href: "/fonts/harmonyos-sans-sc/Semibold.css" },
+1
View File
@@ -39,6 +39,7 @@ CHROME_PATH=/absolute/path/to/chrome npm test
- `review-purchase.e2e.test.mjs`:镜像订单未付复用、取消后重建、已付授权延迟不重复收费;级联候选与默认、结算、购物车恢复一致。 - `review-purchase.e2e.test.mjs`:镜像订单未付复用、取消后重建、已付授权延迟不重复收费;级联候选与默认、结算、购物车恢复一致。
- `review-fields.e2e.test.mjs`:常见 ASCII 规则本地拦截、PCRE 专有规则交后端校验;实例随机密码实际提交及安全源失效、无偏采样边界。 - `review-fields.e2e.test.mjs`:常见 ASCII 规则本地拦截、PCRE 专有规则交后端校验;实例随机密码实际提交及安全源失效、无偏采样边界。
- `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。 - `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。
- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。
- `review-ui-foundation.e2e.test.mjs`:OpenVue/Deni 的手机导航焦点约束、Escape/离页关闭、1024/1025 断点与桌面恢复、手机表格字段标签和可操作的窄屏编辑弹窗。 - `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-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点;首页用户 ID 复制按钮在各宽度下不拆行(该问题只在 Firefox 出现,场景额外启动 Playwright Firefox,需先执行 `npx playwright install firefox`)。
- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。 - `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。
+286
View File
@@ -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 };
}),
);