Files
TJWaterFrontend_Refine/src/components/threeDimensional/ThreeDimensionalScene.tsx
T
jiang d013f34493
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
fix(scene): resume chunk downloads after proxy interruptions
Whole-file loading still failed after adding caching because the public proxy interrupted large GLB response bodies. Download validated 1 MiB ranges, retry transient failures up to three attempts, and persist completed chunks so reloads resume from their last boundary.

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00

414 lines
16 KiB
TypeScript

"use client";
import { NavigationLink } from "@components/loading/NavigationLink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FiAlertCircle,
FiAlertTriangle,
FiBox,
FiCheckCircle,
FiRefreshCw,
} from "react-icons/fi";
import { useProject } from "@/contexts/ProjectContext";
import { getRoundedCurrentTimelineMinutes } from "@components/olmap/core/Controls/timelineTime";
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
import {
ThreeDimensionalControls,
type ControlTab,
} from "./ThreeDimensionalControls";
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
import {
fetchPressureDevices,
fetchSceneFrame,
supportsThreeDimensionalScene,
type PressureDevice,
type SceneFrame,
type SceneModelIndex,
ZJB_PROJECT_CODE,
ZJB_SCENE_MODEL_ID,
} from "./sceneData";
import {
isSceneRuntimeMessage,
SCENE_CHANNEL,
SCENE_PROTOCOL_VERSION,
type SceneAssetSelection,
type SceneCommand,
type SceneHostMessage,
type SceneRuntimeState,
} from "./sceneProtocol";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
const SCENE_LOAD_TIMEOUT_MS = 120_000;
const initialRuntimeState: SceneRuntimeState = {
mode: "network",
status: "正在载入三维模型",
contextVisible: true,
roofVisible: true,
displayMode: "global",
style: {
scale: 6,
mode: "uniform",
color: "#098ed0",
missingColor: "#89949d",
lowColor: "#2b83ba",
highColor: "#e66c37",
opacity: 1,
roughness: 0.3,
metalness: 0.22,
nodes: true,
direction: "none",
arrowColor: "#f2b447",
autoRange: true,
min: 0,
max: 3,
},
styleSummary: {},
appearance: {
preset: "day",
exposure: 0.95,
contextOpacity: 0.4,
shadows: true,
effects: true,
quality: "standard",
},
camera: { active: null, note: "正在准备观察位置", views: [] },
};
const emptyFrame: SceneFrame = {
selectedTime: "",
resultTime: null,
payload: null,
stats: {
simulationNodes: 0,
simulationLinks: 0,
scadaOverrides: 0,
missingNodes: 0,
missingLinks: 0,
ignoredElements: 0,
},
warnings: [],
};
type SceneHostPayload =
| Pick<Extract<SceneHostMessage, { type: "results" }>, "type" | "payload">
| Pick<Extract<SceneHostMessage, { type: "clear-results" }>, "type">
| Pick<Extract<SceneHostMessage, { type: "command" }>, "type" | "command">;
const formatDateTime = (value: Date | string) =>
new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(typeof value === "string" ? new Date(value) : value);
const toQueryTime = (selectedDate: Date, currentTime: number) => {
const queryTime = new Date(selectedDate);
queryTime.setHours(Math.floor(currentTime / 60), currentTime % 60, 0, 0);
return queryTime;
};
export default function ThreeDimensionalScene() {
const project = useProject();
const projectCode = project?.networkName?.trim().toLowerCase() ?? "";
const isZjbProject = supportsThreeDimensionalScene(projectCode);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const frameRevisionRef = useRef(0);
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
const [selectedDate, setSelectedDate] = useState(() => new Date());
const [currentTime, setCurrentTime] = useState(() =>
getRoundedCurrentTimelineMinutes(),
);
const [model, setModel] = useState<SceneModelIndex | null>(null);
const [runtimeState, setRuntimeState] =
useState<SceneRuntimeState>(initialRuntimeState);
const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
const [controlsOpen, setControlsOpen] = useState(true);
const [controlTab, setControlTab] = useState<ControlTab>("scene");
const [timelineOpen, setTimelineOpen] = useState(true);
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
const [pressureMappingWarning, setPressureMappingWarning] = useState<string | null>(null);
const [frame, setFrame] = useState<SceneFrame>(emptyFrame);
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);
const resolvedCurrentTime = useMemo(() => {
const bounded = Math.min(durationMinutes, Math.max(0, currentTime));
return Math.floor(bounded / stepMinutes) * stepMinutes;
}, [currentTime, durationMinutes, stepMinutes]);
const postToScene = useCallback((message: SceneHostPayload) => {
iframeRef.current?.contentWindow?.postMessage(
{
channel: SCENE_CHANNEL,
version: SCENE_PROTOCOL_VERSION,
projectCode: ZJB_PROJECT_CODE,
modelId: ZJB_SCENE_MODEL_ID,
...message,
} satisfies SceneHostMessage,
window.location.origin,
);
}, []);
const sendCommand = useCallback(
(command: SceneCommand) => postToScene({ type: "command", command }),
[postToScene],
);
const retryScene = useCallback(() => {
setModel(null);
setSelection(null);
setRuntimeState(initialRuntimeState);
setSceneError(null);
setSceneLoading(true);
setSceneResourceError(false);
setSceneStarted(false);
setSceneKey((value) => value + 1);
}, []);
useEffect(() => {
if (!isZjbProject) return;
const onMessage = (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
event.source !== iframeRef.current?.contentWindow ||
!isSceneRuntimeMessage(event.data) ||
event.data.projectCode !== ZJB_PROJECT_CODE ||
event.data.modelId !== ZJB_SCENE_MODEL_ID
) {
return;
}
const message = event.data;
if (message.type === "ready") {
if (!Array.isArray(message.nodeIds) || !Array.isArray(message.linkIds) || !message.state) {
setSceneError("三维场景返回了无效的模型索引。");
return;
}
setModel({
modelId: message.modelId,
nodeIds: new Set(message.nodeIds.map(String)),
linkIds: new Set(message.linkIds.map(String)),
});
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") {
setRuntimeState(message.state);
setSceneError(null);
} else if (message.type === "selection-changed") {
setSelection(message.selection);
if (message.selection) {
setControlsOpen(true);
setControlTab("properties");
}
} else if (message.type === "error") {
setSceneError(message.message || "三维场景执行命令失败。");
} else if (message.type === "results-applied") {
setSceneError(null);
} else if (message.type === "results-cleared") {
setSceneError(null);
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [isZjbProject, sceneKey]);
useEffect(() => {
// 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, sceneLoading, sceneStarted]);
useEffect(() => {
if (!isZjbProject) return;
const controller = new AbortController();
fetchPressureDevices(controller.signal)
.then((devices) => {
setPressureDevices(devices);
setPressureMappingWarning(null);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setPressureDevices([]);
setPressureMappingWarning(
error instanceof Error
? `压力测点映射不可用:${error.message}`
: "压力测点映射不可用。",
);
});
return () => controller.abort();
}, [isZjbProject]);
const queryTime = useMemo(
() => toQueryTime(selectedDate, resolvedCurrentTime),
[resolvedCurrentTime, selectedDate],
);
useEffect(() => {
if (!isZjbProject || !model) return;
const revision = frameRevisionRef.current + 1;
frameRevisionRef.current = revision;
const controller = new AbortController();
const timerId = window.setTimeout(() => {
setLoading(true);
setLoadError(null);
fetchSceneFrame({ queryTime, model, pressureDevices, signal: controller.signal })
.then((nextFrame) => {
if (controller.signal.aborted || revision !== frameRevisionRef.current) return;
setFrame(nextFrame);
postToScene(
nextFrame.payload
? { type: "results", payload: nextFrame.payload }
: { type: "clear-results" },
);
})
.catch((error: unknown) => {
if (controller.signal.aborted || revision !== frameRevisionRef.current) return;
setFrame({ ...emptyFrame, selectedTime: queryTime.toISOString() });
postToScene({ type: "clear-results" });
setLoadError(error instanceof Error ? error.message : "当前时间帧加载失败。");
})
.finally(() => {
if (!controller.signal.aborted && revision === frameRevisionRef.current) setLoading(false);
});
}, 180);
return () => {
window.clearTimeout(timerId);
controller.abort();
};
}, [isZjbProject, model, postToScene, pressureDevices, queryTime, refreshVersion]);
if (!isZjbProject) {
return (
<main className="h-full bg-slate-100 p-4 md:p-8">
<section className="flex max-w-2xl gap-3 rounded-2xl border border-blue-200/70 bg-white/70 p-4 text-slate-700 shadow-lg shadow-slate-900/5 backdrop-blur-xl">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-blue-600 text-xl text-white">
<FiBox aria-hidden="true" />
</span>
<div>
<h1 className="font-semibold text-slate-900">当前项目未配置三维场景</h1>
<p className="mt-1 text-sm leading-6 text-slate-600">
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
</p>
<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">
返回管网在线模拟
</NavigationLink>
</div>
</section>
</main>
);
}
const hasFrame = frame.payload !== null;
const frameStatusText = loading
? "正在读取时间帧"
: loadError
? "数据请求失败"
: hasFrame
? "时间帧已应用"
: "该时刻无模拟结果";
const dataWarnings = [pressureMappingWarning, ...frame.warnings].filter(
(warning): warning is string => Boolean(warning),
);
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 || 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">
<FiAlertCircle className="shrink-0 text-lg" />
<span className="min-w-0 flex-1">{loadError || sceneError}</span>
{sceneError && <button type="button" onClick={retryScene} className="min-h-10 shrink-0 rounded-lg px-3 font-medium transition hover:bg-red-100/80 active:scale-95">重试场景</button>}
</div>
)}
<section
aria-label="三维场景数据状态"
className="absolute left-2 top-16 z-20 flex max-w-[calc(100%-1rem)] items-center gap-2 rounded-xl bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] py-1.5 pl-3 pr-1 [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_12px_35px_rgba(15,43,69,0.18)] ring-1 ring-white/55 md:left-[76px] md:top-4 md:max-w-[420px]"
>
{loading ? (
<span className="h-[18px] w-[18px] shrink-0 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600" />
) : loadError || !hasFrame ? (
<FiAlertCircle className={loadError ? "shrink-0 text-lg text-red-600" : "shrink-0 text-lg text-slate-400"} />
) : (
<FiCheckCircle className="shrink-0 text-lg text-emerald-600" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-slate-900">
{frameStatusText}
</p>
<p className="truncate text-[11px] tabular-nums text-slate-500">
{frame.resultTime
? `${formatDateTime(frame.resultTime)} · ${frame.stats.simulationNodes} 节点 · ${frame.stats.simulationLinks} 连接 · SCADA ${frame.stats.scadaOverrides}`
: `选择 ${formatDateTime(queryTime)}`}
</p>
</div>
{dataWarnings.length > 0 && (
<FiAlertTriangle aria-label="数据警告" title={dataWarnings.join(" ")} className="shrink-0 text-lg text-amber-600" />
)}
<button type="button" aria-label="重新读取当前时间帧" title="重新读取当前时间帧" disabled={loading || !model} onClick={() => setRefreshVersion((value) => value + 1)} className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-lg text-slate-500 transition hover:bg-white/60 hover:text-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-35">
<FiRefreshCw className={loading ? "animate-spin" : undefined} />
</button>
</section>
{timelineOpen && (
<ThreeDimensionalTimeline
selectedDate={selectedDate}
currentTime={resolvedCurrentTime}
durationMinutes={durationMinutes}
stepMinutes={stepMinutes}
disabled={!model}
sidePanelOpen={controlsOpen}
onClose={() => setTimelineOpen(false)}
onSelectedDateChange={setSelectedDate}
onCurrentTimeChange={setCurrentTime}
/>
)}
<ThreeDimensionalControls
open={controlsOpen}
activeTab={controlTab}
ready={Boolean(model) && !sceneLoading && !sceneResourceError}
state={runtimeState}
selection={selection}
timelineOpen={timelineOpen}
onOpenChange={setControlsOpen}
onTimelineOpenChange={setTimelineOpen}
onTabChange={setControlTab}
onCommand={sendCommand}
/>
</main>
);
}