feat(frontend): cache 3D assets and restore loading feedback
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s

Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material.

Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries.

Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
This commit is contained in:
2026-10-16 18:13:04 +08:00
parent c4a90be0d3
commit 4a522bf42a
23 changed files with 938 additions and 59 deletions
+96
View File
@@ -0,0 +1,96 @@
import { expect, test, type Page } from "@playwright/test";
import { mockBackend } from "./support/mockBackend";
test.setTimeout(90_000);
async function prepare(page: Page) {
await mockBackend(page);
await page.route("**/api/v1/admin/**", (route) => route.fulfill({
json: route.request().url().endsWith("/users/me")
? { id: "playwright-user", is_system_admin: true, system_role: "system_admin" }
: [],
}));
}
test("菜单点击后立即反馈,等待页面响应时显示路由骨架", async ({ page }) => {
await prepare(page);
await page.goto("/system-admin");
await expect(page.getByRole("link", { name: "审计日志", exact: true })).toBeVisible();
await page.locator("[data-page-scroll-content]").evaluate((element) => { element.scrollTop = element.scrollHeight; });
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
let waiting = false;
await page.route("**/audit-logs?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
waiting = true;
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "审计日志", exact: true }).click();
await expect.poll(() => waiting).toBe(true);
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible({ timeout: 1500 });
await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible();
} finally { release(); }
await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
test("地图页面切换复用原地图,并显示目标面板的既有骨架", async ({ page }) => {
await prepare(page);
await page.route("**/ows?**", (route) => route.fulfill({ json: { type: "FeatureCollection", features: [] } }));
await page.route("**/api/v1/scada-devices", (route) => route.fulfill({ json: [] }));
await page.goto("/network-simulation");
const map = page.locator(".ol-viewport").first();
await expect(map).toBeVisible({ timeout: 60_000 });
await map.evaluate((element) => element.setAttribute("data-navigation-map", "original"));
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/scada-data-cleaning?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "SCADA 数据清洗", exact: true }).click();
await expect(page.getByRole("status", { name: "正在加载数据清洗", exact: true })).toBeVisible();
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "none");
await page.screenshot({ path: "test-results/navigation-map-skeleton.png" });
} finally { release(); }
await expect(page).toHaveURL(/\/scada-data-cleaning$/);
await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "contents");
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
await page.goBack();
await expect(page).toHaveURL(/\/network-simulation$/);
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
test("移动端收起菜单后仍显示页面切换反馈,窄屏无溢出", async ({ page }) => {
await prepare(page);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/system-admin");
await page.getByRole("button", { name: "open drawer", exact: true }).click();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/audit-logs?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "审计日志", exact: true }).click();
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 812 });
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible();
await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/navigation-mobile-${width}.png` });
}
} finally { release(); }
await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
+35
View File
@@ -4,6 +4,41 @@ import { mockBackend } from "./support/mockBackend";
test.setTimeout(180_000);
test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => {
await mockBackend(page);
await page.addInitScript(() => {
localStorage.setItem("active_project", "playwright-project");
localStorage.setItem("MAP_WORKSPACE", "zjb");
localStorage.setItem("NETWORK_NAME", "zjb");
});
let release: () => void = () => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
const failedModel = "**/models/detail_roof.glb*";
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
await page.goto("/three-dimensional-scene");
const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]');
await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}));
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 800 });
expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME");
await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` });
}
release();
await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败");
await page.unroute(failedModel);
await scene.getByRole("button", { name: "重新载入场景" }).click();
await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 });
await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled();
expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}))).toEqual(loadingMaterial);
});
type CameraProbe = {
active: string | null;
position: number[];
+165
View File
@@ -0,0 +1,165 @@
import { expect, test } from "@playwright/test";
const root = "/three-dimensional/zjb/v29/";
const preview = root + "preview.html?host=platform2&v=35-resource-cache";
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
test.setTimeout(120_000);
test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => {
let requests = 0;
await page.route("**/water-network-v28.json*", async (route) => {
requests++;
if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } });
return route.continue();
});
await page.goto(preview);
await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 });
const cached = await page.evaluate((base) => new Promise((resolve, reject) => {
const request = indexedDB.open("tjwater-scene-assets-v1", 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href);
read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); };
read.onerror = () => { reject(read.error); database.close(); };
};
}), root);
expect(cached).toBe(false);
await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
expect(requests).toBe(2);
});
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
const requests: string[] = [];
let block = false;
// Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching.
await context.route("**/three-dimensional/**", (route) => {
if (isAsset(route.request().url())) {
requests.push(route.request().url());
if (block) return route.abort("failed");
}
return route.continue();
});
const coldStart = Date.now();
await page.goto(preview);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const coldMs = Date.now() - coldStart;
expect(requests).toHaveLength(10);
requests.length = 0;
block = true;
await page.close();
const reopened = await context.newPage();
const warmStart = Date.now();
await reopened.goto(preview);
await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 });
expect(await reopened.evaluate(() =>
(window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active,
)).toBe("overview");
expect(requests).toEqual([]);
console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length }));
});
test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => {
let release: () => void = () => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
const failedModel = "**/models/detail_roof.glb*";
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
await page.goto(preview);
await expect(page.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
await expect(page.getByRole("progressbar")).toHaveAttribute("value", "9");
await expect(page.locator("#boot-detail")).toContainText(/已读取 \d+\.\d MB/);
await page.screenshot({ path: "test-results/scene-loading-desktop.png" });
await page.setViewportSize({ width: 320, height: 640 });
await page.screenshot({ path: "test-results/scene-loading-mobile.png" });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
release();
await expect(page.locator("#boot-title")).toHaveText("场景资源加载失败");
await expect(page.locator("#boot-detail")).toContainText("detail_roof.glb");
await expect(page.locator("#boot")).toHaveAttribute("aria-busy", "false");
await expect(page.getByRole("button", { name: "重新载入场景" })).toBeVisible();
await page.screenshot({ path: "test-results/scene-loading-error.png" });
await page.unroute(failedModel);
await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
});
test("同一资源合并并发请求,摘要改变后重新下载并覆盖旧缓存", async ({ page }) => {
let requests = 0;
await page.route("**/cache-probe.json*", (route) => {
requests++;
return route.fulfill({ json: { revision: new URL(route.request().url()).searchParams.get("v") } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
const first = await Promise.all([
cache.load("cache-probe.json", "first", decode),
cache.load("cache-probe.json", "first", decode),
]);
const updated = await cache.load("cache-probe.json", "second", decode);
const freshClient = createSceneResourceCache(location.origin + base);
const cached = await freshClient.load("cache-probe.json", "second", decode);
return { first, updated, cached };
}, root);
expect(requests).toBe(2);
expect(result.first).toEqual([{ revision: "first" }, { revision: "first" }]);
expect(result.updated).toEqual({ revision: "second" });
expect(result.cached).toEqual({ revision: "second" });
});
for (const failure of ["denied", "quota"] as const) {
test(`浏览器缓存${failure === "denied" ? "被禁用" : "空间不足"}时仍可下载并解析资源`, async ({ page }) => {
await page.goto(root + "manifest.json");
await page.route("**/cache-probe.json*", (route) => route.fulfill({ json: { ok: true } }));
const result = await page.evaluate(async ({ base, failure }) => {
if (failure === "denied") {
Object.defineProperty(window, "indexedDB", { get() { throw new Error("denied"); } });
} else {
const transaction = IDBDatabase.prototype.transaction;
IDBDatabase.prototype.transaction = function (...args: Parameters<typeof transaction>) {
if (args[1] === "readwrite") throw new DOMException("Full", "QuotaExceededError");
return transaction.apply(this, args);
};
}
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
return cache.load("cache-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
}, { base: root, failure });
expect(result).toEqual({ ok: true });
});
}
test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", async ({ page }) => {
let requests = 0;
await page.route("**/cache-probe.json*", (route) => {
requests++;
return requests === 1
? route.fulfill({ status: 503, body: "unavailable" })
: requests === 2
? route.fulfill({ body: "invalid json" })
: route.fulfill({ json: { ok: true } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
const errors: string[] = [];
for (let attempt = 0; attempt < 2; attempt++) {
try { await cache.load("cache-probe.json", "first", decode); }
catch (error) { errors.push(String(error)); }
}
return { errors, recovered: await cache.load("cache-probe.json", "first", decode) };
}, root);
expect(requests).toBe(3);
expect(result.errors).toHaveLength(2);
expect(result.errors[0]).toContain("HTTP 503");
expect(result.recovered).toEqual({ ok: true });
});