feat(frontend): cache 3D assets and restore loading feedback
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:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FiAlertCircle,
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
type SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=34-detailed-network-context";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
const initialRuntimeState: SceneRuntimeState = {
|
||||
mode: "network",
|
||||
@@ -138,6 +138,9 @@ export default function ThreeDimensionalScene() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [sceneError, setSceneError] = useState<string | null>(null);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const [sceneResourceError, setSceneResourceError] = useState(false);
|
||||
const [sceneStarted, setSceneStarted] = useState(false);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [sceneKey, setSceneKey] = useState(0);
|
||||
|
||||
@@ -169,6 +172,9 @@ export default function ThreeDimensionalScene() {
|
||||
setSelection(null);
|
||||
setRuntimeState(initialRuntimeState);
|
||||
setSceneError(null);
|
||||
setSceneLoading(true);
|
||||
setSceneResourceError(false);
|
||||
setSceneStarted(false);
|
||||
setSceneKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
@@ -198,6 +204,17 @@ export default function ThreeDimensionalScene() {
|
||||
});
|
||||
setRuntimeState(message.state);
|
||||
setSceneError(null);
|
||||
setSceneLoading(false);
|
||||
setSceneResourceError(false);
|
||||
return;
|
||||
}
|
||||
if (message.type === "loading") {
|
||||
if (typeof message.active === "boolean") {
|
||||
setSceneLoading(message.active);
|
||||
setSceneStarted(true);
|
||||
setSceneResourceError(message.failed === true);
|
||||
if (message.active) setSceneError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "scene-state") {
|
||||
@@ -222,12 +239,14 @@ export default function ThreeDimensionalScene() {
|
||||
}, [isZjbProject, sceneKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject || model) return;
|
||||
// Once the viewer starts, its per-request inactivity timeout owns recovery.
|
||||
// A long but progressing first download should not hit a total-load deadline.
|
||||
if (!isZjbProject || model || !sceneLoading || sceneStarted) return;
|
||||
const timerId = window.setTimeout(() => {
|
||||
setSceneError("三维模型载入超时,请检查静态资源后重试。");
|
||||
}, SCENE_LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [isZjbProject, model, sceneKey]);
|
||||
}, [isZjbProject, model, sceneKey, sceneLoading, sceneStarted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject) return;
|
||||
@@ -301,9 +320,9 @@ export default function ThreeDimensionalScene() {
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
|
||||
</p>
|
||||
<Link href="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
|
||||
<NavigationLink to="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
|
||||
返回管网在线模拟
|
||||
</Link>
|
||||
</NavigationLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() {
|
||||
|
||||
return (
|
||||
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
|
||||
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className="block h-full w-full border-0" />
|
||||
|
||||
{!model && !sceneError && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-100/55 text-sm text-slate-600 backdrop-blur-sm">
|
||||
<span className="h-8 w-8 animate-spin rounded-full border-[3px] border-blue-200 border-t-blue-600" />
|
||||
正在载入三维模型
|
||||
</div>
|
||||
)}
|
||||
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className={`block h-full w-full border-0 ${!model || sceneLoading || sceneResourceError ? "relative z-40" : ""}`} />
|
||||
|
||||
{(loadError || sceneError) && (
|
||||
<div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4">
|
||||
@@ -387,7 +399,7 @@ export default function ThreeDimensionalScene() {
|
||||
<ThreeDimensionalControls
|
||||
open={controlsOpen}
|
||||
activeTab={controlTab}
|
||||
ready={Boolean(model)}
|
||||
ready={Boolean(model) && !sceneLoading && !sceneResourceError}
|
||||
state={runtimeState}
|
||||
selection={selection}
|
||||
timelineOpen={timelineOpen}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
renderRevision: number;
|
||||
assets: Array<{ file: string; sha256: string }>;
|
||||
networkContext: string[];
|
||||
networkModel: { file: string; metadata: string; sha256: string };
|
||||
networkModel: { file: string; metadata: string; sha256: string; metadataSha256: string };
|
||||
sourceCatalogFile?: string;
|
||||
bindingsFile?: string;
|
||||
};
|
||||
@@ -48,6 +48,9 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
});
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.file))).toBe(true);
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
|
||||
expect(createHash("sha256").update(
|
||||
readFileSync(join(sceneRoot, manifest.networkModel.metadata)),
|
||||
).digest("hex")).toBe(manifest.networkModel.metadataSha256);
|
||||
expect(manifest.sourceCatalogFile).toBeUndefined();
|
||||
expect(manifest.bindingsFile).toBeUndefined();
|
||||
|
||||
@@ -67,6 +70,8 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
"network-style.mjs",
|
||||
"integration.mjs",
|
||||
"render-effects.mjs",
|
||||
"resourceCache.mjs",
|
||||
"loadingProgress.mjs",
|
||||
"vendor/meshopt_decoder.module.js",
|
||||
"vendor/meshoptimizer-LICENSE.md",
|
||||
"vendor/three/LICENSE",
|
||||
|
||||
@@ -145,6 +145,7 @@ export type SceneRuntimeMessage = HostMessageBase &
|
||||
state: SceneRuntimeState;
|
||||
}
|
||||
| { type: "scene-state"; state: SceneRuntimeState }
|
||||
| { type: "loading"; active: boolean; failed?: boolean }
|
||||
| { type: "selection-changed"; selection: SceneAssetSelection | null }
|
||||
| { type: "results-applied"; summary: SceneStyleSummary }
|
||||
| { type: "results-cleared" }
|
||||
|
||||
Reference in New Issue
Block a user