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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user