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
+6 -1
View File
@@ -3,7 +3,12 @@
import type { ReactNode } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
import { MapNavigationContent } from "@components/loading/NavigationFeedback";
export default function MapLayout({ children }: { children: ReactNode }) {
return <MapComponent>{children}</MapComponent>;
return (
<MapComponent>
<MapNavigationContent>{children}</MapNavigationContent>
</MapComponent>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { usePathname } from "next/navigation";
import { MapRouteSkeleton } from "@components/loading/PageLoadingSkeleton";
import { useNavigationFeedback } from "@components/loading/NavigationFeedback";
export default function Loading() {
const pathname = usePathname();
const { pendingPath } = useNavigationFeedback();
return <MapRouteSkeleton pathname={pendingPath ?? pathname} />;
}
+24 -18
View File
@@ -6,6 +6,10 @@ import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header";
import { Title } from "@components/title";
import { AppSider } from "@components/sider/AppSider";
import {
NavigationFeedbackProvider,
PageNavigationContent,
} from "@components/loading/NavigationFeedback";
import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation";
@@ -32,24 +36,26 @@ export default async function MainLayout({
}
return (
<ThemedLayout
Header={Header}
Title={Title}
Sider={AppSider}
childrenBoxProps={{
sx: {
flex: 1,
minHeight: 0,
overflow: "auto",
p: 0,
},
}}
containerBoxProps={{
sx: { height: "100vh", overflow: "hidden" },
}}
>
{children}
</ThemedLayout>
<NavigationFeedbackProvider>
<ThemedLayout
Header={Header}
Title={Title}
Sider={AppSider}
childrenBoxProps={{
sx: {
flex: 1,
minHeight: 0,
overflow: "hidden",
p: 0,
},
}}
containerBoxProps={{
sx: { height: "100vh", overflow: "hidden" },
}}
>
<PageNavigationContent>{children}</PageNavigationContent>
</ThemedLayout>
</NavigationFeedbackProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
export default function Loading() {
return <PageLoadingSkeleton />;
}
@@ -1,17 +1,13 @@
"use client";
import dynamic from "next/dynamic";
import { Box, CircularProgress } from "@mui/material";
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
const ThreeDimensionalScene = dynamic(
() => import("@components/threeDimensional/ThreeDimensionalScene"),
{
ssr: false,
loading: () => (
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
<CircularProgress size={32} />
</Box>
),
loading: () => <PageLoadingSkeleton pathname="/three-dimensional-scene" />,
},
);
+4 -1
View File
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { NavigationLink } from "@components/loading/NavigationLink";
import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
@@ -46,6 +47,8 @@ type RefineContextProps = {
defaultMode?: string;
};
const appRouterProvider = { ...routerProvider, Link: NavigationLink };
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>,
) => (
@@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import {
MapNavigationContent,
NavigationFeedbackProvider,
PageNavigationContent,
useNavigationFeedback,
} from "./NavigationFeedback";
import { mapSkeletonForPath } from "./PageLoadingSkeleton";
import { MAP_SKELETON_VARIANTS } from "./mapComponentSkeletonConfig";
let mockPathname = "/network-simulation";
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
function Harness() {
const { reportPending } = useNavigationFeedback();
return (
<>
<button onClick={() => reportPending("first", "/scada-data-cleaning", true)}>清洗</button>
<button onClick={() => reportPending("second", "/audit-logs", true)}>审计</button>
<button onClick={() => reportPending("first", "/scada-data-cleaning", false)}>取消旧导航</button>
<button onClick={() => reportPending("second", "/audit-logs", false)}>结束导航</button>
<PageNavigationContent>
<div data-testid="map">原有地图</div>
<MapNavigationContent><input aria-label="页面草稿" defaultValue="保留输入" /></MapNavigationContent>
</PageNavigationContent>
</>
);
}
describe("page navigation feedback", () => {
beforeEach(() => { mockPathname = "/network-simulation"; });
it("shows the destination skeleton while preserving the map and a cancelled page's draft", () => {
render(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
const map = screen.getByTestId("map");
const draft = screen.getByRole("textbox", { name: "页面草稿" });
fireEvent.click(screen.getByText("清洗"));
expect(screen.getByRole("status", { name: "正在加载数据清洗" })).toBeVisible();
expect(screen.getByTestId("map")).toBe(map);
expect(map).toBeVisible();
expect(draft).not.toBeVisible();
fireEvent.click(screen.getByText("取消旧导航"));
expect(draft).toBeVisible();
expect(draft).toHaveValue("保留输入");
expect(screen.queryByRole("progressbar", { name: "正在切换页面" })).not.toBeInTheDocument();
});
it("does not dismiss a newer destination when the previous link settles", () => {
render(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
fireEvent.click(screen.getByText("清洗"));
fireEvent.click(screen.getByText("审计"));
fireEvent.click(screen.getByText("取消旧导航"));
expect(screen.getByRole("status", { name: "正在加载审计日志" })).toBeVisible();
expect(screen.getByRole("progressbar", { name: "正在切换页面" })).toBeVisible();
fireEvent.click(screen.getByText("结束导航"));
expect(screen.queryByRole("status", { name: "正在加载审计日志" })).not.toBeInTheDocument();
});
it("hands off to route/component loading boundaries as soon as history commits", () => {
const { rerender } = render(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
fireEvent.click(screen.getByText("清洗"));
expect(screen.getByRole("progressbar", { name: "正在切换页面" })).toBeVisible();
mockPathname = "/scada-data-cleaning";
rerender(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
expect(screen.queryByRole("progressbar", { name: "正在切换页面" })).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "页面草稿" })).toBeVisible();
});
it("connects every existing map skeleton to its exact public route", () => {
const paths = [
"/network-simulation", "/scada-data-cleaning", "/health-risk-analysis", "/monitoring-place-optimization",
...["burst-detection", "burst-location", "burst-simulation", "contaminant-simulation", "dma-leak-detection", "flushing-analysis"].map((name) => "/hydraulic-simulation/" + name),
];
expect(paths.map(mapSkeletonForPath).sort()).toEqual([...MAP_SKELETON_VARIANTS].sort());
expect(mapSkeletonForPath("/system-admin")).toBeUndefined();
});
});
@@ -0,0 +1,98 @@
"use client";
import { Box, LinearProgress } from "@mui/material";
import { usePathname } from "next/navigation";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
mapSkeletonForPath,
MapRouteSkeleton,
PageLoadingSkeleton,
} from "./PageLoadingSkeleton";
type PendingNavigation = { id: string; pathname: string };
type NavigationFeedback = {
pendingPath: string | null;
reportPending: (id: string, pathname: string, pending: boolean) => void;
};
const NavigationContext = createContext<NavigationFeedback>({
pendingPath: null,
reportPending: () => {},
});
export const useNavigationFeedback = () => useContext(NavigationContext);
export function NavigationFeedbackProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const [navigation, setNavigation] = useState<PendingNavigation | null>(null);
const reportPending = useCallback((id: string, target: string, pending: boolean) => {
setNavigation((current) => {
if (!pending) return current?.id === id ? null : current;
return current?.id === id && current.pathname === target ? current : { id, pathname: target };
});
}, []);
// History commits can precede a page's lazy component. Its loading.tsx and
// dynamic fallback take over at that point, without an artificial delay.
const pendingPath = navigation && navigation.pathname !== pathname ? navigation.pathname : null;
const value = useMemo(() => ({ pendingPath, reportPending }), [pendingPath, reportPending]);
return (
<NavigationContext.Provider value={value}>
{children}
</NavigationContext.Provider>
);
}
export function PageNavigationContent({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { pendingPath } = useNavigationFeedback();
const withinMap = Boolean(mapSkeletonForPath(pathname) && pendingPath && mapSkeletonForPath(pendingPath));
return (
<Box
data-page-navigation-content
aria-busy={Boolean(pendingPath)}
sx={{ position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }}
>
<Box
data-page-scroll-content
inert={Boolean(pendingPath) && !withinMap}
aria-hidden={Boolean(pendingPath) && !withinMap}
sx={{ height: "100%", overflow: "auto" }}
>
{children}
</Box>
{pendingPath && (
<>
<LinearProgress
aria-label="正在切换页面"
sx={{ position: "absolute", top: 0, left: 0, right: 0, zIndex: 1401, height: 3 }}
/>
{!withinMap && (
<Box sx={{ position: "absolute", inset: 0, zIndex: 1400, overflow: "hidden" }}>
<PageLoadingSkeleton pathname={pendingPath} />
</Box>
)}
</>
)}
</Box>
);
}
export function MapNavigationContent({ children }: { children: ReactNode }) {
const { pendingPath } = useNavigationFeedback();
return (
<>
<Box data-map-page-content sx={{ display: pendingPath ? "none" : "contents" }}>
{children}
</Box>
{pendingPath && <MapRouteSkeleton pathname={pendingPath} />}
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { RouterProvider } from "@refinedev/core";
import Link, { useLinkStatus } from "next/link";
import { forwardRef, useEffect, useId, type ComponentProps } from "react";
import { useNavigationFeedback } from "./NavigationFeedback";
function NavigationLinkStatus({ to }: { to: string }) {
const { pending } = useLinkStatus();
const { reportPending } = useNavigationFeedback();
const id = useId();
const pathname = to.split(/[?#]/)[0];
useEffect(() => {
reportPending(id, pathname, pending);
return () => reportPending(id, pathname, false);
}, [id, pathname, pending, reportPending]);
return null;
}
export const NavigationLink = forwardRef<
HTMLAnchorElement,
ComponentProps<NonNullable<RouterProvider["Link"]>>
>(
function NavigationLink({ to, children, ...props }, ref) {
return (
<Link href={to} {...props} ref={ref}>
{children}
<NavigationLinkStatus to={to} />
</Link>
);
},
);
@@ -0,0 +1,110 @@
"use client";
import { Box, CircularProgress, Skeleton, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import { MapPanelSkeleton, MapTimelineSkeleton } from "./MapComponentSkeletons";
import {
MAP_SKELETON_CONFIGS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
const sceneLoadingSurface = {
width: "min(100%, 360px)",
p: 3,
boxSizing: "border-box",
borderRadius: "16px",
background: "linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28))",
backdropFilter: "blur(24px) saturate(170%) contrast(94%)",
WebkitBackdropFilter: "blur(24px) saturate(170%) contrast(94%)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)",
};
const mapPaths: Record<string, MapSkeletonVariant> = {
"/network-simulation": "network-simulation",
"/scada-data-cleaning": "scada-data-cleaning",
"/health-risk-analysis": "health-risk-analysis",
"/monitoring-place-optimization": "monitoring-place-optimization",
"/hydraulic-simulation/burst-detection": "burst-detection",
"/hydraulic-simulation/burst-location": "burst-location",
"/hydraulic-simulation/burst-simulation": "burst-simulation",
"/hydraulic-simulation/contaminant-simulation": "contaminant-simulation",
"/hydraulic-simulation/dma-leak-detection": "dma-leak-detection",
"/hydraulic-simulation/flushing-analysis": "flushing-analysis",
};
export const mapSkeletonForPath = (
pathname: string,
): MapSkeletonVariant | undefined => mapPaths[pathname];
export const pageTitleForPath = (pathname: string) => {
const variant = mapSkeletonForPath(pathname);
if (variant) return MAP_SKELETON_CONFIGS[variant].title;
return ({
"/system-admin": "系统管理",
"/audit-logs": "审计日志",
"/three-dimensional-scene": "三维场景",
} as Record<string, string>)[pathname] ?? "页面";
};
export function MapRouteSkeleton({ pathname }: { pathname: string }) {
const variant = mapSkeletonForPath(pathname);
if (!variant) return null;
return (
<>
<MapPanelSkeleton variant={variant} />
{(variant === "network-simulation" || variant === "health-risk-analysis") && (
<MapTimelineSkeleton />
)}
</>
);
}
export function PageLoadingSkeleton({ pathname }: { pathname?: string }) {
const currentPath = usePathname();
const path = pathname ?? currentPath;
if (path === "/three-dimensional-scene") {
return (
<Box
role="status"
aria-live="polite"
aria-label="正在加载三维场景"
sx={{ height: "100%", minHeight: 0, display: "grid", placeItems: "center", p: 2, boxSizing: "border-box", bgcolor: "#e2e8e9" }}
>
<Box sx={sceneLoadingSurface}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
<CircularProgress size={20} />
<Typography sx={{ fontSize: 14, fontWeight: 600, color: "#0f172a" }}>
正在准备三维场景
</Typography>
</Box>
<Typography sx={{ mt: 1.5, fontSize: 12, lineHeight: 1.75, color: "#334155" }}>
正在载入页面,随后将检查本地模型缓存。
</Typography>
</Box>
</Box>
);
}
if (mapSkeletonForPath(path)) {
return (
<Box sx={{ position: "relative", height: "100%", minHeight: 320, overflow: "hidden", bgcolor: "#f1f5f9" }}>
<MapRouteSkeleton pathname={path} />
</Box>
);
}
const title = pageTitleForPath(path);
return (
<Box
role="status"
aria-live="polite"
aria-label={`正在加载${title}`}
sx={{ p: { xs: 2, md: 3 }, height: "100%", boxSizing: "border-box", bgcolor: "background.default" }}
>
<Typography sx={{ fontSize: 16, fontWeight: 600, mb: 3 }}>正在加载{title}</Typography>
<Skeleton variant="rounded" height={40} sx={{ maxWidth: 480, mb: 3 }} />
{[0, 1, 2, 3].map((row) => (
<Skeleton key={row} variant="rounded" height={56} sx={{ mb: 1.5 }} />
))}
</Box>
);
}
@@ -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" }