feat(3d): integrate ZJB scene with project context
Generic Container CI/CD / test-build-publish (push) Successful in 17s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 18s

This commit is contained in:
2026-09-11 18:49:55 +08:00
parent 3ebe3328aa
commit 35b6819459
76 changed files with 109136 additions and 22 deletions
@@ -0,0 +1,400 @@
"use client";
import Link from "next/link";
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";
const SCENE_LOAD_TIMEOUT_MS = 30_000;
const initialRuntimeState: SceneRuntimeState = {
mode: "network",
status: "正在载入三维模型",
contextVisible: true,
roofVisible: true,
displayMode: "global",
style: {
scale: 8,
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,
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(false);
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 [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);
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);
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(() => {
if (!isZjbProject || model) return;
const timerId = window.setTimeout(() => {
setSceneError("三维模型载入超时,请检查静态资源后重试。");
}, SCENE_LOAD_TIMEOUT_MS);
return () => window.clearTimeout(timerId);
}, [isZjbProject, model, sceneKey]);
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>
<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">
返回管网在线模拟
</Link>
</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 && !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>
)}
{(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)}
state={runtimeState}
selection={selection}
timelineOpen={timelineOpen}
onOpenChange={setControlsOpen}
onTimelineOpenChange={setTimelineOpen}
onTabChange={setControlTab}
onCommand={sendCommand}
/>
</main>
);
}