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,20 @@
"use client";
import dynamic from "next/dynamic";
import { Box, CircularProgress } from "@mui/material";
const ThreeDimensionalScene = dynamic(
() => import("@components/threeDimensional/ThreeDimensionalScene"),
{
ssr: false,
loading: () => (
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
<CircularProgress size={32} />
</Box>
),
},
);
export default function ThreeDimensionalScenePage() {
return <ThreeDimensionalScene />;
}
+18
View File
@@ -27,6 +27,7 @@ import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { supportsThreeDimensionalScene } from "@components/threeDimensional/sceneData";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
@@ -38,6 +39,7 @@ import {
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
ViewInAr as ViewInArIcon,
} from "@mui/icons-material";
type RefineContextProps = {
@@ -63,6 +65,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const currentProjectCode = useProjectStore(
(state) => state.currentProjectCode,
);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
@@ -209,6 +214,19 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
};
const resources = [
...(supportsThreeDimensionalScene(currentProjectCode) &&
can(permissionCodes.webgisView)
? [
{
name: "三维场景",
list: "/three-dimensional-scene",
meta: {
icon: <ViewInArIcon />,
label: "三维场景",
},
},
]
: []),
...(can(permissionCodes.simulationView)
? [
{
+3 -3
View File
@@ -46,8 +46,8 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
const [showProjectSelector, setShowProjectSelector] = useState(false);
const [showChatbox, setShowChatbox] = useState(false);
const open = Boolean(anchorEl);
const setCurrentProjectId = useProjectStore(
(state) => state.setCurrentProjectId,
const setActiveProjectContext = useProjectStore(
(state) => state.setCurrentProject,
);
const { data: user } = useGetIdentity<IUser>();
@@ -78,7 +78,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
localStorage.removeItem(`${workspace}_map_view`);
setCurrentProjectId(projectId || networkName || workspace);
setActiveProjectContext(projectId || networkName || workspace, networkName);
setShowProjectSelector(false);
window.location.reload();
};
@@ -0,0 +1,110 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { ThreeDimensionalControls } from "./ThreeDimensionalControls";
import type { SceneRuntimeState } from "./sceneProtocol";
const state: 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: "overview",
note: "供水总览",
views: [
{
id: "overview",
label: "供水总览",
mode: "network",
saved: false,
},
],
},
};
describe("ThreeDimensionalControls", () => {
it("sends typed scene commands from platform controls", () => {
const onCommand = jest.fn();
const onTimelineOpenChange = jest.fn();
render(
<ThreeDimensionalControls
open
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={jest.fn()}
onTimelineOpenChange={onTimelineOpenChange}
onTabChange={jest.fn()}
onCommand={onCommand}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
});
it("shows selected asset fields and linked assets in the property tab", () => {
const onCommand = jest.fn();
render(
<ThreeDimensionalControls
open
activeTab="properties"
ready
state={state}
selection={{
assetId: "inp:node:J-1",
elementId: "J-1",
kind: "节点",
title: "节点 · J-1",
sections: [
{
title: "运行结果",
fields: [{ label: "压力", value: "31.200 mH₂O" }],
},
],
neighbors: [{ id: "P-1", label: "P-1" }],
}}
timelineOpen
onOpenChange={jest.fn()}
onTimelineOpenChange={jest.fn()}
onTabChange={jest.fn()}
onCommand={onCommand}
/>,
);
expect(screen.getByText("节点 · J-1")).toBeInTheDocument();
expect(screen.getByText("31.200 mH₂O")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "P-1" }));
expect(onCommand).toHaveBeenCalledWith({ name: "select-asset", assetId: "P-1" });
});
});
@@ -0,0 +1,482 @@
"use client";
import clsx from "clsx";
import { useState, type ReactNode } from "react";
import {
FiBox,
FiCamera,
FiChevronRight,
FiClock,
FiCrosshair,
FiDroplet,
FiHome,
FiInfo,
FiLayers,
FiMaximize,
FiPlus,
FiRotateCcw,
FiSliders,
FiTrash2,
FiX,
} from "react-icons/fi";
import type {
SceneAssetSelection,
SceneCommand,
SceneMode,
SceneRuntimeState,
} from "./sceneProtocol";
const sceneModes: Array<{ mode: SceneMode; label: string }> = [
{ mode: "network", label: "供水管网" },
{ mode: "hydraulic", label: "泵组与管网" },
{ mode: "map", label: "站区轻量版" },
{ mode: "detail", label: "站房精细版" },
{ mode: "pump", label: "CAD 泵房参考" },
{ mode: "meters", label: "设备样件" },
];
const glassSurface =
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [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_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
const fieldClass =
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
export type ControlTab = "scene" | "style" | "properties";
export type ThreeDimensionalControlsProps = {
open: boolean;
activeTab: ControlTab;
ready: boolean;
state: SceneRuntimeState;
selection: SceneAssetSelection | null;
timelineOpen: boolean;
onOpenChange: (open: boolean) => void;
onTimelineOpenChange: (open: boolean) => void;
onTabChange: (tab: ControlTab) => void;
onCommand: (command: SceneCommand) => void;
};
export function ThreeDimensionalControls({
open,
activeTab,
ready,
state,
selection,
timelineOpen,
onOpenChange,
onTimelineOpenChange,
onTabChange,
onCommand,
}: ThreeDimensionalControlsProps) {
const [cameraLabel, setCameraLabel] = useState("");
const selectTab = (tab: ControlTab) => {
onTabChange(tab);
onOpenChange(true);
};
return (
<>
<nav
aria-label="三维场景快捷工具"
className={clsx(
glassSurface,
"absolute left-2 top-2 z-20 flex max-w-[calc(100%-1rem)] items-center gap-0.5 rounded-xl p-1 opacity-90 transition-opacity duration-200 hover:opacity-100 md:left-4 md:top-4 md:flex-col",
)}
>
<ToolButton
label="场景与视角"
active={open && activeTab === "scene"}
disabled={!ready}
onClick={() => selectTab("scene")}
>
<FiBox />
</ToolButton>
<ToolButton
label="管网样式"
active={open && activeTab === "style"}
disabled={!ready}
onClick={() => selectTab("style")}
>
<FiDroplet />
</ToolButton>
<ToolButton
label="时间轴"
active={timelineOpen}
disabled={!ready}
onClick={() => {
onTimelineOpenChange(!timelineOpen);
if (!timelineOpen && window.matchMedia("(max-width: 767px)").matches) {
onOpenChange(false);
}
}}
>
<FiClock />
</ToolButton>
<ToolButton
label="构件属性"
active={open && activeTab === "properties"}
disabled={!ready}
onClick={() => selectTab("properties")}
>
<FiInfo />
</ToolButton>
<span aria-hidden="true" className="mx-1 h-6 w-px bg-slate-300/70 md:my-1 md:h-px md:w-6" />
<ToolButton
label="供水总览"
disabled={!ready}
onClick={() => onCommand({ name: "visit-camera", viewId: "overview" })}
>
<FiHome />
</ToolButton>
<ToolButton
label="管网俯视"
disabled={!ready}
onClick={() => onCommand({ name: "visit-camera", viewId: "plan" })}
>
<FiLayers />
</ToolButton>
<ToolButton
label="适应视图"
disabled={!ready}
onClick={() => onCommand({ name: "fit-view" })}
>
<FiMaximize />
</ToolButton>
</nav>
{open && (
<aside
aria-label="三维场景控制面板"
className={clsx(
glassSurface,
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96",
)}
>
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-blue-600 text-white shadow-sm shadow-blue-700/20">
<FiSliders aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-900"></p>
<p className="truncate text-[11px] text-slate-500">{state.status}</p>
</div>
<IconButton label="收起三维场景工具" onClick={() => onOpenChange(false)}>
<FiX />
</IconButton>
</div>
<div role="tablist" aria-label="三维场景工具分类" className="grid grid-cols-3 border-b border-white/45 bg-sky-50/10 px-2 pt-1">
<TabButton active={activeTab === "scene"} onClick={() => onTabChange("scene")} icon={<FiLayers />}></TabButton>
<TabButton active={activeTab === "style"} onClick={() => onTabChange("style")} icon={<FiDroplet />}></TabButton>
<TabButton active={activeTab === "properties"} onClick={() => onTabChange("properties")} icon={<FiInfo />}></TabButton>
</div>
<div
aria-disabled={!ready}
className={clsx(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
!ready && "pointer-events-none opacity-50",
)}
>
{activeTab === "scene" && (
<div className="space-y-6">
<ControlSection title="显示内容">
<div className="grid grid-cols-2 gap-2">
{sceneModes.map((item) => (
<button
key={item.mode}
type="button"
onClick={() => onCommand({ name: "set-mode", mode: item.mode })}
className={clsx(
"min-h-10 rounded-lg border px-2 text-sm font-medium transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
state.mode === item.mode
? "border-blue-600 bg-blue-600 text-white shadow-sm shadow-blue-700/20"
: "border-slate-300/70 bg-white/35 text-slate-700 hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700",
)}
>
{item.label}
</button>
))}
</div>
</ControlSection>
<ControlSection title="观察位置" description={state.camera.note}>
<div className="space-y-1">
{state.camera.views.map((view) => (
<div key={view.id} className="flex items-center gap-1">
<button
type="button"
onClick={() => onCommand({ name: "visit-camera", viewId: view.id })}
className={clsx(
"flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
state.camera.active === view.id
? "bg-blue-600 text-white shadow-sm"
: "text-slate-700 hover:bg-white/55 hover:text-blue-700",
)}
>
<FiCamera className="shrink-0" />
<span className="truncate">{view.label}</span>
<FiChevronRight className="ml-auto shrink-0 opacity-50" />
</button>
{view.saved && (
<IconButton
label={`移除视角 ${view.label}`}
onClick={() => onCommand({ name: "remove-camera", viewId: view.id })}
>
<FiTrash2 />
</IconButton>
)}
</div>
))}
</div>
<div className="mt-2 flex gap-2">
<input
aria-label="当前视角名称"
className={fieldClass}
maxLength={24}
placeholder="当前视角名称"
value={cameraLabel}
onChange={(event) => setCameraLabel(event.target.value)}
/>
<button
type="button"
disabled={!cameraLabel.trim()}
onClick={() => {
onCommand({ name: "save-camera", label: cameraLabel.trim() });
setCameraLabel("");
}}
className="inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
>
<FiPlus />
</button>
</div>
</ControlSection>
<ControlSection title="场景显示">
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
<SelectField
label="管网展示比例"
value={state.displayMode}
onChange={(value) => onCommand({ name: "set-display-mode", mode: value as "global" | "coordinated" })}
options={[{ value: "global", label: "全局比例" }, { value: "coordinated", label: "泵房协调比例" }]}
/>
</ControlSection>
</div>
)}
{activeTab === "style" && (
<div className="space-y-6">
<ControlSection title="管网表达">
<SelectField
label="着色方式"
value={state.style.mode}
onChange={(value) => onCommand({ name: "set-style", patch: { mode: value as SceneRuntimeState["style"]["mode"] } })}
options={[
{ value: "uniform", label: "统一颜色" },
{ value: "pressure", label: "压力" },
{ value: "velocity", label: "流速" },
{ value: "direction", label: "流向" },
]}
/>
<LabeledSlider label={`管径倍率 ${state.style.scale}×`} value={state.style.scale} min={1} max={12} step={1} onChange={(value) => onCommand({ name: "set-style", patch: { scale: value } })} />
<LabeledSlider label={`不透明度 ${Math.round(state.style.opacity * 100)}%`} value={state.style.opacity} min={0.15} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { opacity: value } })} />
<LabeledSlider label={`表面粗糙度 ${state.style.roughness.toFixed(2)}`} value={state.style.roughness} min={0.05} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { roughness: value } })} />
<SwitchRow label="显示连接节点" checked={state.style.nodes} onChange={(checked) => onCommand({ name: "set-style", patch: { nodes: checked } })} />
<SelectField
label="方向箭头"
value={state.style.direction}
onChange={(value) => onCommand({ name: "set-style", patch: { direction: value as SceneRuntimeState["style"]["direction"] } })}
options={[{ value: "none", label: "隐藏" }, { value: "results", label: "按后端结果" }, { value: "topology", label: "按编号方向" }]}
/>
</ControlSection>
<ControlSection title="结果色带">
<div className="grid grid-cols-3 gap-2">
<ColorInput label="低值" value={state.style.lowColor} onChange={(value) => onCommand({ name: "set-style", patch: { lowColor: value } })} />
<ColorInput label="高值" value={state.style.highColor} onChange={(value) => onCommand({ name: "set-style", patch: { highColor: value } })} />
<ColorInput label="无数据" value={state.style.missingColor} onChange={(value) => onCommand({ name: "set-style", patch: { missingColor: value } })} />
</div>
<div aria-hidden="true" className="h-2 rounded-full ring-1 ring-white/60" style={{ background: `linear-gradient(90deg, ${state.style.lowColor}, ${state.style.highColor})` }} />
<SwitchRow label="按当前结果自动设定范围" checked={state.style.autoRange} onChange={(checked) => onCommand({ name: "set-style", patch: { autoRange: checked } })} />
{!state.style.autoRange && (
<div className="grid grid-cols-2 gap-2">
<NumberInput label="下限" value={state.style.min} onCommit={(value) => onCommand({ name: "set-style", patch: { min: value } })} />
<NumberInput label="上限" value={state.style.max} onCommit={(value) => onCommand({ name: "set-style", patch: { max: value } })} />
</div>
)}
</ControlSection>
<ControlSection title="光照与画质">
<div className="grid grid-cols-2 gap-2">
<SelectField label="场景光照" value={state.appearance.preset} onChange={(value) => onCommand({ name: "set-appearance", patch: { preset: value as SceneRuntimeState["appearance"]["preset"] } })} options={[{ value: "day", label: "清晰日光" }, { value: "studio", label: "设备展厅" }, { value: "evening", label: "傍晚暖光" }]} />
<SelectField label="画质" value={state.appearance.quality} onChange={(value) => onCommand({ name: "set-appearance", patch: { quality: value as SceneRuntimeState["appearance"]["quality"] } })} options={[{ value: "standard", label: "标准" }, { value: "high", label: "高质量" }]} />
</div>
<LabeledSlider label={`亮度 ${state.appearance.exposure.toFixed(2)}`} value={state.appearance.exposure} min={0.55} max={1.6} step={0.05} onChange={(value) => onCommand({ name: "set-appearance", patch: { exposure: value } })} />
<SwitchRow label="柔和阴影" checked={state.appearance.shadows} onChange={(checked) => onCommand({ name: "set-appearance", patch: { shadows: checked } })} />
<SwitchRow label="空间遮蔽与边缘平滑" checked={state.appearance.effects} onChange={(checked) => onCommand({ name: "set-appearance", patch: { effects: checked } })} />
</ControlSection>
<button type="button" onClick={() => onCommand({ name: "reset-style" })} className="flex min-h-10 w-full items-center justify-center gap-2 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700 active:scale-[0.99]">
<FiRotateCcw />
</button>
</div>
)}
{activeTab === "properties" && <AssetProperties selection={selection} onCommand={onCommand} />}
</div>
</aside>
)}
</>
);
}
function ToolButton({ label, active = false, disabled = false, onClick, children }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void; children: ReactNode }) {
return (
<div className="group relative">
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={clsx(
"grid h-10 w-10 place-items-center rounded-lg text-[18px] transition duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 disabled:cursor-not-allowed disabled:opacity-35",
active
? "bg-blue-600 text-white shadow-md shadow-blue-700/20 ring-1 ring-blue-400/30"
: "text-slate-600 hover:bg-blue-50/80 hover:text-blue-700",
)}
>
{children}
</button>
<span className="pointer-events-none absolute left-full top-1/2 z-50 ml-2 hidden -translate-y-1/2 whitespace-nowrap rounded-md bg-slate-900/90 px-2 py-1 text-xs text-white opacity-0 shadow-md transition group-hover:opacity-100 md:block">
{label}
</span>
</div>
);
}
function IconButton({ label, disabled = false, onClick, children }: { label: string; disabled?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-lg text-slate-500 transition hover:bg-white/65 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
{children}
</button>
);
}
function TabButton({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: ReactNode; children: ReactNode }) {
return (
<button type="button" role="tab" aria-selected={active} onClick={onClick} className={clsx("relative flex min-h-11 items-center justify-center gap-1.5 rounded-t-lg text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40", active ? "text-blue-700 after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-blue-600" : "text-slate-500 hover:bg-white/35 hover:text-slate-800")}>
{icon}{children}
</button>
);
}
function ControlSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<section>
<div className="mb-2">
<h3 className="text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{title}</h3>
{description && <p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>}
</div>
<div className="space-y-2.5">{children}</div>
</section>
);
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
return (
<label className="block">
<span className="mb-1 block text-xs text-slate-500">{label}</span>
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
);
}
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return (
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700">
<span>{label}</span>
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} />
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
</label>
);
}
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
return (
<label className="block">
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span>
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} />
</label>
);
}
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="text-center">
<input type="color" aria-label={`${label}颜色`} value={value} onChange={(event) => onChange(event.target.value)} className="h-10 w-full cursor-pointer rounded-lg border border-slate-300/70 bg-white/45 p-1" />
<span className="mt-1 block text-xs text-slate-500">{label}</span>
</label>
);
}
function NumberInput({ label, value, onCommit }: { label: string; value: number; onCommit: (value: number) => void }) {
return (
<label className="block">
<span className="mb-1 block text-xs text-slate-500">{label}</span>
<input key={`${label}-${value}`} className={fieldClass} type="number" defaultValue={value} step={0.1} onBlur={(event) => { const next = Number(event.target.value); if (Number.isFinite(next)) onCommit(next); }} />
</label>
);
}
function AssetProperties({ selection, onCommand }: { selection: SceneAssetSelection | null; onCommand: (command: SceneCommand) => void }) {
if (!selection) {
return (
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
<span className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-blue-200/70 bg-blue-50/60 text-2xl text-blue-600"><FiInfo /></span>
<h3 className="text-sm font-semibold text-slate-800"></h3>
<p className="mt-2 max-w-64 text-xs leading-5 text-slate-500"></p>
</div>
);
}
return (
<div className="space-y-5">
<div>
<p className="text-base font-semibold text-slate-900">{selection.title}</p>
<p className="mt-0.5 text-xs tabular-nums text-slate-500"> {selection.elementId}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={() => onCommand({ name: "locate-selection" })} className="flex min-h-10 items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.98]"><FiCrosshair /></button>
<button type="button" onClick={() => onCommand({ name: "clear-selection" })} className="min-h-10 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:bg-white/65 active:scale-[0.98]"></button>
</div>
{selection.sections.map((section) => (
<section key={section.title}>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{section.title}</h3>
<dl>
{section.fields.map((field, index) => (
<div key={`${section.title}-${field.label}`} className={clsx("grid min-h-9 grid-cols-[minmax(86px,.42fr)_minmax(0,1fr)] items-start gap-4 py-2", index > 0 && "border-t border-slate-200/55")}>
<dt className="text-xs text-slate-500">{field.label}</dt>
<dd className="text-right text-sm tabular-nums text-slate-800 [overflow-wrap:anywhere]">{field.value}</dd>
</div>
))}
</dl>
</section>
))}
{selection.neighbors.length > 0 && (
<section>
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500"></h3>
<div className="flex flex-wrap gap-2">
{selection.neighbors.map((neighbor) => (
<button key={neighbor.id} type="button" onClick={() => onCommand({ name: "select-asset", assetId: neighbor.id })} className="min-h-9 rounded-lg border border-slate-300/70 bg-white/35 px-3 text-sm text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700">{neighbor.label}</button>
))}
</div>
</section>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,121 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
const mockDraggable = jest.fn(({ children }: { children: ReactNode }) => children);
jest.mock("react-draggable", () => ({
__esModule: true,
default: (props: { children: ReactNode }) => mockDraggable(props),
}));
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
describe("ThreeDimensionalTimeline", () => {
it("renders as an open toolbar and can be collapsed", () => {
const onClose = jest.fn();
render(
<ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
currentTime={720}
durationMinutes={1440}
stepMinutes={15}
onClose={onClose}
onSelectedDateChange={jest.fn()}
onCurrentTimeChange={jest.fn()}
/>,
);
expect(screen.getByRole("region", { name: "三维场景时间轴" })).toBeInTheDocument();
expect(screen.getByText("结果时间轴")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "收起时间轴" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows the timeline to be dragged vertically away from the bottom", () => {
render(
<ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
currentTime={720}
durationMinutes={1440}
stepMinutes={15}
onClose={jest.fn()}
onSelectedDateChange={jest.fn()}
onCurrentTimeChange={jest.fn()}
/>,
);
expect(mockDraggable).toHaveBeenLastCalledWith(
expect.not.objectContaining({ bounds: expect.anything() }),
);
});
it("uses the themed calendar and returns the selected day", () => {
const onSelectedDateChange = jest.fn();
render(
<ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
currentTime={720}
durationMinutes={1440}
stepMinutes={15}
onClose={jest.fn()}
onSelectedDateChange={onSelectedDateChange}
onCurrentTimeChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText("数据日期")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
expect(screen.getByRole("dialog", { name: "选择数据日期" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "2026年9月10日" }));
expect(onSelectedDateChange).toHaveBeenCalledWith(expect.any(Date));
expect(screen.queryByRole("dialog", { name: "选择数据日期" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "播放速度,当前 0.4×" }));
expect(screen.getByRole("listbox", { name: "选择播放速度" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("option", { name: /1.0×/ }));
expect(screen.getByRole("button", { name: "播放速度,当前 1.0×" })).toBeInTheDocument();
});
it("keeps the calendar open when returning to today", () => {
const onSelectedDateChange = jest.fn();
render(
<ThreeDimensionalTimeline
selectedDate={new Date("2026-08-20T12:00:00+08:00")}
currentTime={720}
durationMinutes={1440}
stepMinutes={15}
onClose={jest.fn()}
onSelectedDateChange={onSelectedDateChange}
onCurrentTimeChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
fireEvent.click(screen.getByRole("button", { name: "今天" }));
expect(onSelectedDateChange).toHaveBeenCalledWith(expect.any(Date));
expect(screen.getByRole("dialog", { name: "选择数据日期" })).toBeInTheDocument();
});
it("supports fast year and month navigation", () => {
render(
<ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
currentTime={720}
durationMinutes={1440}
stepMinutes={15}
onClose={jest.fn()}
onSelectedDateChange={jest.fn()}
onCurrentTimeChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
fireEvent.click(screen.getByRole("button", { name: "选择月份" }));
expect(screen.getByRole("grid", { name: "2026 年月份" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "选择年份" }));
expect(screen.getByRole("grid", { name: "选择年份" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("gridcell", { name: "2025" }));
expect(screen.getByRole("grid", { name: "2025 年月份" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("gridcell", { name: "8 月" }));
expect(screen.getByText("2025 年 8 月")).toBeInTheDocument();
});
});
@@ -0,0 +1,592 @@
"use client";
import clsx from "clsx";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import Draggable from "react-draggable";
import {
FiCalendar,
FiChevronDown,
FiChevronLeft,
FiChevronRight,
FiPause,
FiPlay,
FiRotateCcw,
FiSkipBack,
FiSkipForward,
FiX,
FiZap,
} from "react-icons/fi";
import {
formatTimelineTime,
getRoundedCurrentTimelineMinutes,
normalizeTimelineMinutes,
} from "@components/olmap/core/Controls/timelineTime";
const DEFAULT_PLAY_INTERVAL_MS = 2_500;
const WEEK_LABELS = ["一", "二", "三", "四", "五", "六", "日"];
const glassClass =
"bg-[rgba(234,244,250,0.62)] [backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [-webkit-backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.90),inset_0_-1px_0_rgba(112,145,168,0.16),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/60";
const calendarGlassClass =
"bg-[rgba(238,246,251,0.88)] [backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [-webkit-backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.96),inset_0_-1px_0_rgba(112,145,168,0.18),0_22px_60px_rgba(15,43,69,0.24)] ring-1 ring-white/75";
export type ThreeDimensionalTimelineProps = {
selectedDate: Date;
currentTime: number;
durationMinutes: number;
stepMinutes: number;
disabled?: boolean;
sidePanelOpen?: boolean;
onClose: () => void;
onSelectedDateChange: (date: Date) => void;
onCurrentTimeChange: (minutes: number) => void;
};
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate());
const isSameDay = (left: Date, right: Date) =>
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate();
const addDays = (date: Date, amount: number) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount, 12);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year} / ${month} / ${day}`;
};
export function ThreeDimensionalTimeline({
selectedDate,
currentTime,
durationMinutes,
stepMinutes,
disabled = false,
sidePanelOpen = false,
onClose,
onSelectedDateChange,
onCurrentTimeChange,
}: ThreeDimensionalTimelineProps) {
const timelineRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const safeCurrentTime = normalizeTimelineMinutes(
previewTime ?? currentTime,
0,
durationMinutes,
);
const advance = useCallback(
(direction: 1 | -1) => {
const next = safeCurrentTime + stepMinutes * direction;
onCurrentTimeChange(
next > durationMinutes ? 0 : next < 0 ? durationMinutes : next,
);
},
[durationMinutes, onCurrentTimeChange, safeCurrentTime, stepMinutes],
);
useEffect(() => {
if (!playing || disabled) return;
const intervalId = window.setInterval(() => advance(1), playIntervalMs);
return () => window.clearInterval(intervalId);
}, [advance, disabled, playIntervalMs, playing]);
const marks = useMemo(
() =>
[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const value = Math.round((durationMinutes * ratio) / stepMinutes) * stepMinutes;
return formatTimelineTime(Math.min(durationMinutes, value), 0, durationMinutes);
}),
[durationMinutes, stepMinutes],
);
const resetToCurrentTime = () => {
const now = new Date();
setPlaying(false);
onSelectedDateChange(now);
onCurrentTimeChange(
getRoundedCurrentTimelineMinutes(now, stepMinutes, durationMinutes),
);
};
const commitPreview = (value?: string) => {
const next = Number(value ?? previewTime ?? safeCurrentTime);
if (!Number.isFinite(next)) return;
setPreviewTime(null);
onCurrentTimeChange(next);
};
const progress = durationMinutes > 0
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
: 0;
return (
<div
className={clsx(
"pointer-events-none absolute inset-x-2 bottom-2 z-20 flex justify-center transition-[right] duration-200 md:left-4 md:bottom-4",
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
)}
>
<Draggable
nodeRef={timelineRef}
handle=".timeline-drag-handle"
cancel="button, input, select, [role='dialog']"
>
<section
ref={timelineRef}
aria-label="三维场景时间轴"
className={clsx(
glassClass,
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
)}
>
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
<button
type="button"
aria-label="收起时间轴"
title="收起时间轴"
onClick={onClose}
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
<FiX />
</button>
</div>
<div className="px-3 pb-3 pt-3 md:px-4 md:pb-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-slate-800"></span>
<span className="rounded-md bg-blue-50/60 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200/55">{stepMinutes} </span>
<time className="ml-auto hidden text-xs tabular-nums text-slate-500 sm:block">
{formatDate(selectedDate)} {formatTimelineTime(safeCurrentTime, 0, durationMinutes)}
</time>
</div>
<div className="space-y-2.5">
<div className="grid gap-2.5 sm:grid-cols-[minmax(260px,0.85fr)_minmax(350px,1.15fr)]">
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
<span className="mb-1.5 block text-[11px] font-medium text-slate-500"></span>
<div className="flex w-full items-center justify-between gap-3">
<SquareButton label="后退一天" disabled={disabled} onClick={() => onSelectedDateChange(addDays(selectedDate, -1))}>
<FiChevronLeft />
</SquareButton>
<CalendarPicker
selectedDate={selectedDate}
disabled={disabled}
onChange={onSelectedDateChange}
/>
<SquareButton
label="前进一天"
disabled={disabled || startOfDay(selectedDate).getTime() >= startOfDay(new Date()).getTime()}
onClick={() => onSelectedDateChange(addDays(selectedDate, 1))}
>
<FiChevronRight />
</SquareButton>
</div>
</div>
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
<span className="mb-1.5 block text-[11px] font-medium text-slate-500"></span>
<div className="flex w-full items-center justify-between gap-3">
<PlaybackSpeedPicker
value={playIntervalMs}
disabled={disabled}
onChange={setPlayIntervalMs}
/>
<div className="flex items-center gap-2">
<TimelineButton label={`后退 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(-1)}><FiSkipBack /></TimelineButton>
<TimelineButton label={playing ? "暂停播放" : "播放时间轴"} disabled={disabled} active={playing} onClick={() => setPlaying((value) => !value)}>{playing ? <FiPause /> : <FiPlay className="translate-x-px" />}</TimelineButton>
<TimelineButton label={`前进 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(1)}><FiSkipForward /></TimelineButton>
</div>
<TimelineButton label="回到当前时刻" disabled={disabled} onClick={resetToCurrentTime}><FiRotateCcw /></TimelineButton>
</div>
</div>
</div>
<div className="min-w-0 rounded-xl bg-white/20 px-3 pb-2 pt-2.5 ring-1 ring-white/35">
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-[11px] text-slate-500"></span>
<strong className="text-sm font-semibold tabular-nums text-slate-800">{formatTimelineTime(safeCurrentTime, 0, durationMinutes)}</strong>
</div>
<div className="relative">
<input
type="range"
aria-label="三维场景查询时刻"
min={0}
max={durationMinutes}
step={stepMinutes}
value={safeCurrentTime}
disabled={disabled}
onChange={(event) => setPreviewTime(Number(event.target.value))}
onPointerUp={(event) => commitPreview(event.currentTarget.value)}
onKeyUp={(event) => commitPreview(event.currentTarget.value)}
onBlur={(event) => previewTime !== null && commitPreview(event.currentTarget.value)}
className="block h-1.5 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-40 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-[3px] [&::-webkit-slider-thumb]:ring-white/80"
style={{ background: `linear-gradient(90deg, #2563eb 0%, #2563eb ${progress}%, rgba(148,163,184,.42) ${progress}%, rgba(148,163,184,.42) 100%)` }}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-0.5">
{[0, 1, 2, 3, 4].map((mark) => <span key={mark} className="h-1 w-1 rounded-full bg-white/90 shadow-sm" />)}
</div>
</div>
<div aria-hidden="true" className="mt-1.5 flex justify-between text-[10px] tabular-nums text-slate-500">
{marks.map((mark, index) => <span key={`${mark}-${index}`} className={clsx(index > 0 && index < marks.length - 1 && "hidden sm:inline")}>{mark}</span>)}
</div>
</div>
</div>
</div>
</section>
</Draggable>
</div>
);
}
type CalendarView = "days" | "months" | "years";
function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const calendarRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
const [focusedDate, setFocusedDate] = useState(() => startOfDay(selectedDate));
const [view, setView] = useState<CalendarView>("days");
const [yearPageStart, setYearPageStart] = useState(() => Math.floor(selectedDate.getFullYear() / 12) * 12);
const today = startOfDay(new Date());
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => {
if (!open || view !== "days") return;
window.requestAnimationFrame(() => {
calendarRef.current
?.querySelector<HTMLButtonElement>("[data-calendar-focused='true']")
?.focus();
});
}, [focusedDate, open, view, visibleMonth]);
const days = useMemo(() => {
const firstWeekday = (visibleMonth.getDay() + 6) % 7;
return Array.from({ length: 42 }, (_, index) =>
new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index - firstWeekday + 1, 12),
);
}, [visibleMonth]);
const selectDate = (date: Date) => {
onChange(date);
setOpen(false);
};
const selectToday = () => {
const now = new Date();
onChange(now);
setFocusedDate(startOfDay(now));
setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1));
setYearPageStart(Math.floor(now.getFullYear() / 12) * 12);
setView("days");
};
const showDate = (date: Date) => {
const bounded = startOfDay(date).getTime() > today.getTime() ? today : startOfDay(date);
setFocusedDate(bounded);
setVisibleMonth(new Date(bounded.getFullYear(), bounded.getMonth(), 1));
};
const handleCalendarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (view !== "days") return;
let next: Date | null = null;
if (event.key === "ArrowLeft") next = addDays(focusedDate, -1);
if (event.key === "ArrowRight") next = addDays(focusedDate, 1);
if (event.key === "ArrowUp") next = addDays(focusedDate, -7);
if (event.key === "ArrowDown") next = addDays(focusedDate, 7);
if (event.key === "PageUp") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), 12);
if (event.key === "PageDown") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), 12);
const weekday = (focusedDate.getDay() + 6) % 7;
if (event.key === "Home") next = addDays(focusedDate, -weekday);
if (event.key === "End") next = addDays(focusedDate, 6 - weekday);
if (!next) return;
event.preventDefault();
showDate(next);
};
const atLatestPeriod =
view === "days"
? visibleMonth.getFullYear() === today.getFullYear() && visibleMonth.getMonth() >= today.getMonth()
: view === "months"
? visibleMonth.getFullYear() >= today.getFullYear()
: yearPageStart + 11 >= today.getFullYear();
const movePeriod = (direction: -1 | 1) => {
if (view === "days") {
setVisibleMonth((date) => new Date(date.getFullYear(), date.getMonth() + direction, 1));
return;
}
if (view === "months") {
setVisibleMonth((date) => new Date(date.getFullYear() + direction, date.getMonth(), 1));
return;
}
setYearPageStart((year) => year + direction * 12);
};
const title = view === "days"
? `${visibleMonth.getFullYear()}${visibleMonth.getMonth() + 1}`
: view === "months"
? `${visibleMonth.getFullYear()}`
: `${yearPageStart}${yearPageStart + 11}`;
const openCalendar = () => {
const selected = startOfDay(selectedDate);
setVisibleMonth(new Date(selected.getFullYear(), selected.getMonth(), 1));
setFocusedDate(selected);
setYearPageStart(Math.floor(selected.getFullYear() / 12) * 12);
setView("days");
setOpen(true);
};
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
aria-label={`选择日期,当前 ${formatDate(selectedDate)}`}
aria-haspopup="dialog"
aria-expanded={open}
disabled={disabled}
onClick={() => {
if (open) setOpen(false);
else openCalendar();
}}
className={clsx(
"flex h-10 min-w-[154px] items-center gap-2 rounded-xl bg-white/48 px-3 text-sm tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
open && "bg-white/78 ring-2 ring-blue-500/35",
)}
>
<FiCalendar className="text-blue-600" />
<span>{formatDate(selectedDate)}</span>
<FiChevronDown className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
ref={calendarRef}
role="dialog"
aria-label="选择数据日期"
onKeyDown={handleCalendarKeyDown}
className={clsx(
calendarGlassClass,
"absolute bottom-[calc(100%+10px)] left-[-46px] z-50 w-[min(312px,calc(100vw-32px))] rounded-2xl p-3 sm:left-0",
)}
>
<div className="mb-2 flex items-center">
<button type="button" aria-label={view === "years" ? "前十二年" : view === "months" ? "上一年" : "上个月"} onClick={() => movePeriod(-1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"><FiChevronLeft /></button>
<button
type="button"
aria-label={view === "days" ? "选择月份" : view === "months" ? "选择年份" : "返回日期"}
aria-live="polite"
onClick={() => setView((current) => current === "days" ? "months" : current === "months" ? "years" : "days")}
className="min-h-10 flex-1 rounded-xl px-2 text-center text-sm font-semibold tabular-nums text-slate-800 transition hover:bg-white/55 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
{title}
</button>
<button type="button" onClick={selectToday} className="min-h-10 rounded-xl px-2.5 text-[11px] font-semibold text-blue-700 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"></button>
<button type="button" aria-label={view === "years" ? "后十二年" : view === "months" ? "下一年" : "下个月"} disabled={atLatestPeriod} onClick={() => movePeriod(1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-25"><FiChevronRight /></button>
</div>
{view === "days" && (
<>
<div className="grid grid-cols-7 text-center">
{WEEK_LABELS.map((label, index) => <span key={label} className={clsx("py-1 text-[10px] font-medium", index > 4 ? "text-blue-600" : "text-slate-500")}>{label}</span>)}
</div>
<div className="grid grid-cols-7 gap-0.5">
{days.map((date) => {
const selected = isSameDay(date, selectedDate);
const current = isSameDay(date, today);
const focused = isSameDay(date, focusedDate);
const outsideMonth = date.getMonth() !== visibleMonth.getMonth();
const future = startOfDay(date).getTime() > today.getTime();
return (
<button
key={date.toISOString()}
type="button"
aria-label={`${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${current ? ",今天" : ""}`}
aria-pressed={selected}
data-calendar-focused={focused}
tabIndex={focused ? 0 : -1}
disabled={future}
onFocus={() => setFocusedDate(startOfDay(date))}
onClick={() => selectDate(date)}
className={clsx(
"relative grid h-9 w-9 place-items-center rounded-xl text-xs tabular-nums transition-[transform,color,background-color,box-shadow] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45 disabled:cursor-not-allowed disabled:opacity-20",
selected
? "bg-blue-600 font-semibold text-white shadow-md shadow-blue-700/20"
: "text-slate-700 hover:bg-white/65 hover:text-blue-700",
outsideMonth && !selected && "text-slate-400",
current && !selected && "font-semibold text-blue-700 after:absolute after:bottom-1 after:h-1 after:w-1 after:rounded-full after:bg-blue-600",
)}
>
{date.getDate()}
</button>
);
})}
</div>
</>
)}
{view === "months" && (
<div role="grid" aria-label={`${visibleMonth.getFullYear()} 年月份`} className="grid grid-cols-3 gap-1 py-1">
{Array.from({ length: 12 }, (_, month) => {
const future = visibleMonth.getFullYear() > today.getFullYear() || (visibleMonth.getFullYear() === today.getFullYear() && month > today.getMonth());
const selected = selectedDate.getFullYear() === visibleMonth.getFullYear() && selectedDate.getMonth() === month;
return (
<button key={month} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), month, 1)); setView("days"); }} className={clsx("min-h-11 rounded-xl text-sm transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{month + 1} </button>
);
})}
</div>
)}
{view === "years" && (
<div role="grid" aria-label="选择年份" className="grid grid-cols-3 gap-1 py-1">
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => {
const future = year > today.getFullYear();
const selected = year === selectedDate.getFullYear();
return (
<button key={year} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { const month = year === today.getFullYear() ? Math.min(visibleMonth.getMonth(), today.getMonth()) : visibleMonth.getMonth(); setVisibleMonth(new Date(year, month, 1)); setView("months"); }} className={clsx("min-h-11 rounded-xl text-sm tabular-nums transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{year}</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
const playbackSpeedOptions = [
{ value: 1000, label: "1.0×", description: "每秒一步" },
{ value: 2500, label: "0.4×", description: "2.5 秒一步" },
{ value: 5000, label: "0.2×", description: "5 秒一步" },
];
function PlaybackSpeedPicker({ value, disabled, onChange }: { value: number; disabled: boolean; onChange: (value: number) => void }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selected = playbackSpeedOptions.find((option) => option.value === value) ?? playbackSpeedOptions[1];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
aria-label={`播放速度,当前 ${selected.label}`}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
className={clsx(
"flex h-10 min-w-[86px] items-center gap-2 rounded-xl bg-white/46 px-2.5 text-xs font-medium tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
open && "bg-white/78 ring-2 ring-blue-500/35",
)}
>
<FiZap aria-hidden="true" className="text-sm text-blue-600" />
<span>{selected.label}</span>
<FiChevronDown aria-hidden="true" className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="listbox"
aria-label="选择播放速度"
className={clsx(
calendarGlassClass,
"absolute bottom-[calc(100%+8px)] left-0 z-50 w-36 overflow-hidden rounded-xl p-1.5",
)}
>
{playbackSpeedOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={clsx(
"flex min-h-11 w-full items-center rounded-lg px-3 text-left transition-[transform,color,background-color] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40",
option.value === value
? "bg-blue-600 text-white shadow-sm shadow-blue-700/20"
: "text-slate-700 hover:bg-white/60 hover:text-blue-700",
)}
>
<span className="text-sm font-semibold tabular-nums">{option.label}</span>
<span className={clsx("ml-auto text-[10px]", option.value === value ? "text-blue-100" : "text-slate-500")}>{option.description}</span>
</button>
))}
</div>
)}
</div>
);
}
function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-white/42 text-base text-slate-600 ring-1 ring-white/60 transition-[transform,color,background-color] hover:bg-blue-50/75 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
{children}
</button>
);
}
function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={clsx(
"grid h-10 w-10 shrink-0 place-items-center rounded-full text-base transition-[transform,color,background-color,box-shadow] duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35",
active
? "bg-blue-600 text-white shadow-md shadow-blue-700/25"
: "bg-slate-100/52 text-slate-600 ring-1 ring-white/55 hover:bg-blue-50/80 hover:text-blue-700",
)}
>
{children}
</button>
);
}
@@ -0,0 +1,92 @@
/** @jest-environment node */
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const sceneRoot = join(
process.cwd(),
"public",
"three-dimensional",
"zjb",
"v29",
);
describe("ZJB three-dimensional runtime assets", () => {
it("ships every model referenced by the production manifest", () => {
const manifest = JSON.parse(
readFileSync(join(sceneRoot, "manifest.json"), "utf8"),
) as {
packageId: string;
renderRevision: number;
assets: Array<{ file: string; sha256: string }>;
networkModel: { file: string; metadata: string; sha256: string };
sourceCatalogFile?: string;
bindingsFile?: string;
};
expect(manifest.packageId).toBe("zjb-web-v29");
expect(manifest.renderRevision).toBe(29);
expect(manifest.assets).toHaveLength(24);
manifest.assets.forEach((asset) => {
const assetPath = join(sceneRoot, asset.file);
expect(existsSync(assetPath)).toBe(true);
expect(createHash("sha256").update(readFileSync(assetPath)).digest("hex")).toBe(
asset.sha256,
);
});
expect(existsSync(join(sceneRoot, manifest.networkModel.file))).toBe(true);
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
expect(manifest.sourceCatalogFile).toBeUndefined();
expect(manifest.bindingsFile).toBeUndefined();
const networkModel = readFileSync(join(sceneRoot, manifest.networkModel.file));
expect(createHash("sha256").update(networkModel).digest("hex")).toBe(
manifest.networkModel.sha256,
);
});
it("includes the offline runtime modules and third-party licenses", () => {
[
"preview.html",
"preview.mjs",
"appearance.mjs",
"asset-inspector.mjs",
"camera-navigation.mjs",
"network-style.mjs",
"integration.mjs",
"render-effects.mjs",
"vendor/meshopt_decoder.module.js",
"vendor/meshoptimizer-LICENSE.md",
"vendor/three/LICENSE",
"vendor/three/three.module.js",
"vendor/three/addons/loaders/GLTFLoader.js",
].forEach((relativePath) => {
expect(existsSync(join(sceneRoot, relativePath))).toBe(true);
});
});
it("keeps the embedded viewer on the versioned same-origin host protocol", () => {
const preview = readFileSync(join(sceneRoot, "preview.mjs"), "utf8");
const html = readFileSync(join(sceneRoot, "preview.html"), "utf8");
const inspector = readFileSync(
join(sceneRoot, "asset-inspector.mjs"),
"utf8",
);
expect(preview).toContain("tjwater:zjb-scene");
expect(preview).toContain("HOST_VERSION=2");
expect(preview).toContain("event.origin===window.location.origin");
expect(preview).toContain("event.source===window.parent");
expect(preview).toContain("postHost('ready'");
expect(preview).toContain("data.type==='results'");
expect(preview).toContain("data.type==='clear-results'");
expect(preview).toContain("data.type!=='command'");
expect(preview).toContain("postHost('selection-changed'");
expect(preview).not.toContain("window.zjbNetwork");
expect(preview).not.toContain("resultsFile");
expect(html).not.toContain("绑定运行结果");
expect(html).not.toContain("resultsFile");
expect(inspector).not.toContain("document.getElementById");
});
});
@@ -0,0 +1,255 @@
import {
buildSceneFrame,
fetchPressureDevices,
fetchSceneFrame,
supportsThreeDimensionalScene,
type SceneModelIndex,
} from "./sceneData";
const mockApiFetch = jest.fn();
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}));
const model: SceneModelIndex = {
modelId: "zjb-water-network-v23",
nodeIds: new Set(["J-1", "J-2"]),
linkIds: new Set(["P-1", "P-2"]),
};
describe("buildSceneFrame", () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
it("enables the scene only for the normalized ZJB project code", () => {
expect(supportsThreeDimensionalScene(" ZJB ")).toBe(true);
expect(supportsThreeDimensionalScene("tjwater_v2")).toBe(false);
expect(supportsThreeDimensionalScene(null)).toBe(false);
});
it("combines one complete simulation frame and overrides pressure with cleaned SCADA", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
model,
nodeRows: [
{
time: "2026-09-11T03:00:00.000Z",
node_id: "J-1",
pressure: 31.2,
},
{
time: "2026-09-11T03:00:00.000Z",
node_id: "J-2",
pressure: 29.7,
},
],
linkRows: [
{
time: "2026-09-11T03:00:00.000Z",
link_id: "P-1",
velocity: 0.82,
flow: -18.4,
status: 1,
},
],
pressureDevices: [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
],
scadaRows: [
{
time: "2026-09-11T03:00:01.000Z",
device_id: "S-1",
monitored_value: 30.8,
cleaned_value: 30.9,
},
],
});
expect(frame.resultTime).toBe("2026-09-11T03:00:00.000Z");
expect(frame.payload?.nodes["J-1"]).toEqual({
pressure: 30.9,
source: "scada",
deviceId: "S-1",
simulationPressure: 31.2,
});
expect(frame.payload?.links["P-1"]).toEqual({
velocity: 0.82,
flow: -18.4,
direction: -1,
status: "open",
});
expect(frame.stats).toMatchObject({
simulationNodes: 2,
simulationLinks: 1,
scadaOverrides: 1,
missingNodes: 0,
missingLinks: 1,
});
});
it("uses monitored SCADA when cleaned data is absent", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
model,
nodeRows: [
{
time: "2026-09-11T03:00:00.000Z",
node_id: "J-1",
pressure: 31.2,
},
],
linkRows: [
{
time: "2026-09-11T03:00:00.000Z",
link_id: "P-1",
velocity: 0.2,
flow: 2,
status: 0,
},
],
pressureDevices: [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
],
scadaRows: [
{
time: "2026-09-11T03:00:00.000Z",
device_id: "S-1",
monitored_value: 30.8,
cleaned_value: null,
},
],
});
expect(frame.payload?.nodes["J-1"]?.pressure).toBe(30.8);
expect(frame.payload?.links["P-1"]?.status).toBe("closed");
});
it("keeps the selected time and returns no payload when a complete frame is absent", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
model,
nodeRows: [
{
time: "2026-09-11T02:45:00.000Z",
node_id: "J-1",
pressure: 31.2,
},
],
linkRows: [],
pressureDevices: [],
scadaRows: [],
});
expect(frame.selectedTime).toBe("2026-09-11T03:00:00.000Z");
expect(frame.resultTime).toBeNull();
expect(frame.payload).toBeNull();
expect(frame.stats.missingNodes).toBe(2);
expect(frame.stats.missingLinks).toBe(2);
});
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
model,
nodeRows: [
{
time: "2026-09-11T03:00:00.000Z",
node_id: "J-UNKNOWN",
pressure: 31.2,
},
],
linkRows: [
{
time: "2026-09-11T03:00:00.000Z",
link_id: "P-UNKNOWN",
velocity: 0.2,
flow: 2,
status: 2,
},
],
pressureDevices: [],
scadaRows: [],
});
expect(frame.payload?.nodes).toEqual({});
expect(frame.payload?.links).toEqual({});
expect(frame.stats.ignoredElements).toBe(2);
});
it("reads pressure mappings from the paginated SCADA device response", async () => {
mockApiFetch.mockResolvedValue({
ok: true,
json: async () => ({
items: [
{ device_id: "S-1", device_type: "pressure", node_id: " J-1 " },
{ device_id: "S-2", device_type: "flow", node_id: "J-2" },
{ device_id: "S-3", device_type: "pressure", node_id: null },
],
total: 3,
limit: 1000,
offset: 0,
}),
});
const devices = await fetchPressureDevices(new AbortController().signal);
expect(devices).toEqual([
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
]);
expect(mockApiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("keeps simulation results when SCADA readings are temporarily unavailable", async () => {
mockApiFetch
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
time: "2026-09-11T03:00:00.000Z",
node_id: "J-1",
pressure: 31.2,
},
],
})
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
time: "2026-09-11T03:00:00.000Z",
link_id: "P-1",
velocity: 0.8,
flow: 12,
status: 1,
},
],
})
.mockResolvedValueOnce({
ok: false,
status: 503,
text: async () => "SCADA service unavailable",
});
const frame = await fetchSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
model,
pressureDevices: [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
],
signal: new AbortController().signal,
});
expect(frame.payload?.nodes["J-1"]).toEqual({
pressure: 31.2,
source: "simulation",
});
expect(frame.stats.scadaOverrides).toBe(0);
expect(frame.warnings).toEqual([
"SCADA 数据暂不可用,当前仅显示模拟结果。",
]);
});
});
@@ -0,0 +1,375 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export const ZJB_PROJECT_CODE = "zjb";
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
export const SCENE_FRAME_TOLERANCE_MS = 2_000;
export const supportsThreeDimensionalScene = (projectCode?: string | null) =>
projectCode?.trim().toLowerCase() === ZJB_PROJECT_CODE;
export type SceneModelIndex = {
modelId: string;
nodeIds: ReadonlySet<string>;
linkIds: ReadonlySet<string>;
};
export type SceneNodeResult = {
pressure: number;
source: "simulation" | "scada";
deviceId?: string;
simulationPressure?: number;
};
export type SceneLinkResult = {
velocity?: number;
flow?: number;
status?: "open" | "closed" | "active";
direction?: -1 | 0 | 1;
};
export type SceneResultsPayload = {
modelId: string;
units: {
velocity: "m/s";
pressure: "mH2O";
flow: "L/s";
};
timestamp: string;
nodes: Record<string, SceneNodeResult>;
links: Record<string, SceneLinkResult>;
};
export type SceneFrameStats = {
simulationNodes: number;
simulationLinks: number;
scadaOverrides: number;
missingNodes: number;
missingLinks: number;
ignoredElements: number;
};
export type SceneFrame = {
selectedTime: string;
resultTime: string | null;
payload: SceneResultsPayload | null;
stats: SceneFrameStats;
warnings: string[];
};
export type PressureDevice = {
device_id: string;
device_type: string;
node_id: string;
};
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
node_id?: string | null;
};
type Page<T> = {
items: T[];
limit: number;
offset: number;
total: number;
};
type RealtimeNodeRow = {
time: string;
node_id: string;
pressure: number | null;
};
type RealtimeLinkRow = {
time: string;
link_id: string;
velocity: number | null;
flow: number | null;
status: number | null;
};
type ScadaReadingRow = {
time: string;
device_id: string;
monitored_value: number | null;
cleaned_value: number | null;
};
const emptyStats = (model: SceneModelIndex): SceneFrameStats => ({
simulationNodes: 0,
simulationLinks: 0,
scadaOverrides: 0,
missingNodes: model.nodeIds.size,
missingLinks: model.linkIds.size,
ignoredElements: 0,
});
const toTimestamp = (value: string) => {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
};
const isFiniteNumber = (value: unknown): value is number =>
typeof value === "number" && Number.isFinite(value);
const frameWindow = (queryTime: Date) => ({
startTime: new Date(queryTime.getTime() - SCENE_FRAME_TOLERANCE_MS),
endTime: new Date(queryTime.getTime() + SCENE_FRAME_TOLERANCE_MS),
});
const rowsAtTime = <T extends { time: string }>(rows: T[], timestamp: number) =>
rows.filter((row) => toTimestamp(row.time) === timestamp);
const resolveCommonFrameTime = (
queryTime: Date,
nodeRows: RealtimeNodeRow[],
linkRows: RealtimeLinkRow[],
) => {
const target = queryTime.getTime();
const nodeTimes = new Set(
nodeRows
.map((row) => toTimestamp(row.time))
.filter((time): time is number => time !== null),
);
const commonTimes = Array.from(
new Set(
linkRows
.map((row) => toTimestamp(row.time))
.filter(
(time): time is number =>
time !== null &&
nodeTimes.has(time) &&
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
),
),
);
if (commonTimes.length === 0) return null;
return commonTimes.sort(
(left, right) => Math.abs(left - target) - Math.abs(right - target),
)[0];
};
const normalizeLinkStatus = (
value: number | null,
): SceneLinkResult["status"] => {
if (!isFiniteNumber(value)) return undefined;
if (value <= 0) return "closed";
if (value === 1) return "open";
return "active";
};
const nearestScadaReadings = (
rows: ScadaReadingRow[],
resultTimestamp: number,
) => {
const byDevice = new Map<string, ScadaReadingRow>();
rows.forEach((row) => {
const timestamp = toTimestamp(row.time);
if (
timestamp === null ||
Math.abs(timestamp - resultTimestamp) > SCENE_FRAME_TOLERANCE_MS
) {
return;
}
const existing = byDevice.get(row.device_id);
const existingTimestamp = existing ? toTimestamp(existing.time) : null;
if (
existingTimestamp === null ||
Math.abs(timestamp - resultTimestamp) <
Math.abs(existingTimestamp - resultTimestamp)
) {
byDevice.set(row.device_id, row);
}
});
return byDevice;
};
export const buildSceneFrame = ({
queryTime,
model,
nodeRows,
linkRows,
pressureDevices,
scadaRows,
}: {
queryTime: Date;
model: SceneModelIndex;
nodeRows: RealtimeNodeRow[];
linkRows: RealtimeLinkRow[];
pressureDevices: PressureDevice[];
scadaRows: ScadaReadingRow[];
}): SceneFrame => {
const selectedTime = queryTime.toISOString();
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
if (frameTime === null) {
return {
selectedTime,
resultTime: null,
payload: null,
stats: emptyStats(model),
warnings: [],
};
}
const nodes: Record<string, SceneNodeResult> = {};
const links: Record<string, SceneLinkResult> = {};
let ignoredElements = 0;
rowsAtTime(nodeRows, frameTime).forEach((row) => {
const id = String(row.node_id);
if (!model.nodeIds.has(id)) {
ignoredElements += 1;
return;
}
if (isFiniteNumber(row.pressure)) {
nodes[id] = { pressure: row.pressure, source: "simulation" };
}
});
rowsAtTime(linkRows, frameTime).forEach((row) => {
const id = String(row.link_id);
if (!model.linkIds.has(id)) {
ignoredElements += 1;
return;
}
const result: SceneLinkResult = {};
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity;
if (isFiniteNumber(row.flow)) {
result.flow = row.flow;
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
}
const status = normalizeLinkStatus(row.status);
if (status) result.status = status;
links[id] = result;
});
const simulationNodeCount = Object.keys(nodes).length;
const readingsByDevice = nearestScadaReadings(scadaRows, frameTime);
let scadaOverrides = 0;
pressureDevices.forEach((device) => {
if (!model.nodeIds.has(device.node_id)) {
ignoredElements += 1;
return;
}
const reading = readingsByDevice.get(device.device_id);
if (!reading) return;
const value = isFiniteNumber(reading.cleaned_value)
? reading.cleaned_value
: reading.monitored_value;
if (!isFiniteNumber(value)) return;
const simulationPressure = nodes[device.node_id]?.pressure;
nodes[device.node_id] = {
pressure: value,
source: "scada",
deviceId: device.device_id,
...(simulationPressure === undefined ? {} : { simulationPressure }),
};
scadaOverrides += 1;
});
const resultTime = new Date(frameTime).toISOString();
return {
selectedTime,
resultTime,
payload: {
modelId: model.modelId,
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" },
timestamp: resultTime,
nodes,
links,
},
stats: {
simulationNodes: simulationNodeCount,
simulationLinks: Object.keys(links).length,
scadaOverrides,
missingNodes: Math.max(0, model.nodeIds.size - Object.keys(nodes).length),
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
ignoredElements,
},
warnings: [],
};
};
const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
const response = await apiFetch(url, { signal });
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail || `请求失败:HTTP ${response.status}`);
}
return (await response.json()) as T;
};
export const fetchPressureDevices = async (signal: AbortSignal) => {
const page = await readJson<Page<RawPressureDevice>>(
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
signal,
);
return page.items
.filter(
(device): device is PressureDevice =>
device.device_type?.trim().toLowerCase() === "pressure" &&
typeof device.node_id === "string" &&
device.node_id.trim().length > 0,
)
.map((device) => ({ ...device, node_id: device.node_id.trim() }));
};
export const fetchSceneFrame = async ({
queryTime,
model,
pressureDevices,
signal,
}: {
queryTime: Date;
model: SceneModelIndex;
pressureDevices: PressureDevice[];
signal: AbortSignal;
}) => {
const { startTime, endTime } = frameWindow(queryTime);
const range = new URLSearchParams({
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
});
const scadaRange = new URLSearchParams(range);
scadaRange.set(
"device_ids",
pressureDevices.map((device) => device.device_id).join(","),
);
const [nodeRows, linkRows] = await Promise.all([
readJson<RealtimeNodeRow[]>(
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
signal,
),
readJson<RealtimeLinkRow[]>(
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
signal,
),
]);
let scadaRows: ScadaReadingRow[] = [];
const warnings: string[] = [];
if (pressureDevices.length > 0) {
try {
const rows = await readJson<ScadaReadingRow[]>(
`${config.BACKEND_URL}/api/v1/timeseries/scada-readings?${scadaRange}`,
signal,
);
scadaRows = Array.isArray(rows) ? rows : [];
} catch (error) {
if (signal.aborted) throw error;
warnings.push("SCADA 数据暂不可用,当前仅显示模拟结果。");
}
}
const frame = buildSceneFrame({
queryTime,
model,
nodeRows: Array.isArray(nodeRows) ? nodeRows : [],
linkRows: Array.isArray(linkRows) ? linkRows : [],
pressureDevices,
scadaRows,
});
return { ...frame, warnings };
};
@@ -0,0 +1,32 @@
import {
isSceneRuntimeMessage,
SCENE_CHANNEL,
SCENE_PROTOCOL_VERSION,
} from "./sceneProtocol";
describe("scene protocol", () => {
it("accepts the versioned same-origin message shape", () => {
expect(
isSceneRuntimeMessage({
channel: SCENE_CHANNEL,
version: SCENE_PROTOCOL_VERSION,
type: "results-cleared",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
}),
).toBe(true);
});
it("rejects stale and incomplete messages", () => {
expect(
isSceneRuntimeMessage({
channel: SCENE_CHANNEL,
version: 1,
type: "ready",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
}),
).toBe(false);
expect(isSceneRuntimeMessage({ type: "ready" })).toBe(false);
});
});
@@ -0,0 +1,165 @@
import type { SceneResultsPayload } from "./sceneData";
export const SCENE_CHANNEL = "tjwater:zjb-scene";
export const SCENE_PROTOCOL_VERSION = 2;
export type SceneMode =
| "network"
| "hydraulic"
| "map"
| "detail"
| "pump"
| "meters";
export type SceneDisplayMode = "global" | "coordinated";
export type SceneMetricMode = "uniform" | "velocity" | "pressure" | "direction";
export type SceneDirectionMode = "none" | "topology" | "results";
export type SceneLightingPreset = "day" | "studio" | "evening";
export type SceneQuality = "standard" | "high";
export type SceneNetworkStyle = {
scale: number;
mode: SceneMetricMode;
color: string;
missingColor: string;
lowColor: string;
highColor: string;
opacity: number;
roughness: number;
metalness: number;
nodes: boolean;
direction: SceneDirectionMode;
arrowColor: string;
autoRange: boolean;
min: number;
max: number;
};
export type SceneAppearance = {
preset: SceneLightingPreset;
exposure: number;
shadows: boolean;
effects: boolean;
quality: SceneQuality;
};
export type SceneCameraView = {
id: string;
label: string;
mode: SceneMode;
note?: string;
saved: boolean;
};
export type SceneCameraState = {
active: string | null;
note: string;
views: SceneCameraView[];
};
export type SceneStyleSummary = {
mode?: SceneMetricMode;
min?: number;
max?: number;
dataLinks?: number;
totalLinks?: number;
arrows?: number;
hasResults?: boolean;
resultTime?: string | null;
scale?: number;
};
export type SceneRuntimeState = {
mode: SceneMode;
status: string;
contextVisible: boolean;
roofVisible: boolean;
displayMode: SceneDisplayMode;
style: SceneNetworkStyle;
styleSummary: SceneStyleSummary;
appearance: SceneAppearance;
camera: SceneCameraState;
};
export type SceneAssetField = {
label: string;
value: string;
};
export type SceneAssetSection = {
title: string;
fields: SceneAssetField[];
};
export type SceneAssetNeighbor = {
id: string;
label: string;
};
export type SceneAssetSelection = {
assetId: string;
elementId: string;
kind: string;
title: string;
sections: SceneAssetSection[];
neighbors: SceneAssetNeighbor[];
};
export type SceneCommand =
| { name: "set-mode"; mode: SceneMode }
| { name: "visit-camera"; viewId: string }
| { name: "save-camera"; label: string }
| { name: "remove-camera"; viewId: string }
| { name: "set-style"; patch: Partial<SceneNetworkStyle> }
| { name: "reset-style" }
| { name: "set-display-mode"; mode: SceneDisplayMode }
| { name: "set-appearance"; patch: Partial<SceneAppearance> }
| { name: "toggle-context" }
| { name: "toggle-roof" }
| { name: "fit-view" }
| { name: "select-asset"; assetId: string }
| { name: "locate-selection" }
| { name: "clear-selection" };
type HostMessageBase = {
channel: typeof SCENE_CHANNEL;
version: typeof SCENE_PROTOCOL_VERSION;
projectCode: string;
modelId: string;
};
export type SceneHostMessage = HostMessageBase &
(
| { type: "results"; payload: SceneResultsPayload }
| { type: "clear-results" }
| { type: "command"; command: SceneCommand }
);
export type SceneRuntimeMessage = HostMessageBase &
(
| {
type: "ready";
nodeIds: string[];
linkIds: string[];
state: SceneRuntimeState;
}
| { type: "scene-state"; state: SceneRuntimeState }
| { type: "selection-changed"; selection: SceneAssetSelection | null }
| { type: "results-applied"; summary: SceneStyleSummary }
| { type: "results-cleared" }
| { type: "error"; message: string }
);
export const isSceneRuntimeMessage = (
input: unknown,
): input is SceneRuntimeMessage => {
if (!input || typeof input !== "object") return false;
const message = input as Partial<SceneRuntimeMessage>;
return (
message.channel === SCENE_CHANNEL &&
message.version === SCENE_PROTOCOL_VERSION &&
typeof message.projectCode === "string" &&
typeof message.modelId === "string" &&
typeof message.type === "string"
);
};
+7 -6
View File
@@ -4,7 +4,7 @@ import { ProjectProvider } from "./ProjectContext";
const mockApiFetch = jest.fn();
const mockUseSession = jest.fn();
const mockSetCurrentProjectId = jest.fn();
const mockSetCurrentProject = jest.fn();
jest.mock("next-auth/react", () => ({
useSession: () => mockUseSession(),
@@ -46,8 +46,8 @@ jest.mock("@/store/accessStore", () => ({
jest.mock("@/store/projectStore", () => ({
useProjectStore: (
selector: (state: { setCurrentProjectId: typeof mockSetCurrentProjectId }) => unknown,
) => selector({ setCurrentProjectId: mockSetCurrentProjectId }),
selector: (state: { setCurrentProject: typeof mockSetCurrentProject }) => unknown,
) => selector({ setCurrentProject: mockSetCurrentProject }),
}));
const seedSavedProject = () => {
@@ -64,7 +64,7 @@ describe("ProjectProvider authentication boundary", () => {
beforeEach(() => {
localStorage.clear();
mockApiFetch.mockReset();
mockSetCurrentProjectId.mockReset();
mockSetCurrentProject.mockReset();
mockApiFetch.mockResolvedValue({
ok: true,
json: async () => ({}),
@@ -81,7 +81,7 @@ describe("ProjectProvider authentication boundary", () => {
</ProjectProvider>,
);
expect(mockSetCurrentProjectId).not.toHaveBeenCalled();
expect(mockSetCurrentProject).not.toHaveBeenCalled();
expect(mockApiFetch).not.toHaveBeenCalled();
});
@@ -99,8 +99,9 @@ describe("ProjectProvider authentication boundary", () => {
expect(mockApiFetch).toHaveBeenCalledWith(
"http://backend.test/api/v1/projects/current",
);
expect(mockSetCurrentProjectId).toHaveBeenCalledWith(
expect(mockSetCurrentProject).toHaveBeenCalledWith(
"a2d67c84-fd9d-4feb-a500-c357244b2760",
"fengyang",
);
});
});
+4 -4
View File
@@ -44,8 +44,8 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
state.permissions.includes(permissionCodes.environmentManage),
);
const [isConfigured, setIsConfigured] = useState(false);
const setCurrentProjectId = useProjectStore(
(state) => state.setCurrentProjectId,
const setActiveProjectContext = useProjectStore(
(state) => state.setCurrentProject,
);
const [currentProject, setCurrentProject] = useState({
workspace: config.MAP_WORKSPACE,
@@ -69,7 +69,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
localStorage.removeItem(`${workspace}_map_view`);
setCurrentProject({ workspace, networkName, extent });
setCurrentProjectId(resolvedProjectId);
setActiveProjectContext(resolvedProjectId, networkName);
setIsConfigured(true);
try {
@@ -106,7 +106,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
console.error("Failed to setup project:", error);
}
},
[setCurrentProjectId],
[setActiveProjectContext],
);
useEffect(() => {
+4
View File
@@ -6,6 +6,9 @@ import {
describe("permission mappings", () => {
it("maps protected routes to backend permission codes", () => {
expect(permissionForPath("/three-dimensional-scene")).toBe(
permissionCodes.webgisView,
);
expect(permissionForPath("/system-admin")).toBe(
permissionCodes.environmentManage,
);
@@ -26,6 +29,7 @@ describe("permission mappings", () => {
});
it("uses the same permission codes for resources and routes", () => {
expect(resourcePermissions["三维场景"]).toBe(permissionCodes.webgisView);
expect(resourcePermissions["系统管理"]).toBe(
permissionCodes.environmentManage,
);
+5
View File
@@ -31,6 +31,7 @@ export type AccessContext = {
};
export const resourcePermissions: Record<string, PermissionCode> = {
"三维场景": permissionCodes.webgisView,
"管网在线模拟": permissionCodes.simulationView,
"SCADA 数据清洗": permissionCodes.scadaClean,
"监测点优化布置": permissionCodes.optimizationRun,
@@ -49,6 +50,10 @@ export const pathPermissions: Array<{
prefix: string;
permission: PermissionCode;
}> = [
{
prefix: "/three-dimensional-scene",
permission: permissionCodes.webgisView,
},
{ prefix: "/system-admin", permission: permissionCodes.environmentManage },
{ prefix: "/audit-logs", permission: permissionCodes.auditView },
{ prefix: "/scada-data-cleaning", permission: permissionCodes.scadaClean },
+34 -7
View File
@@ -2,7 +2,9 @@ import { create } from "zustand";
interface ProjectState {
currentProjectId: string | null;
currentProjectCode: string | null;
setCurrentProjectId: (id: string | null) => void;
setCurrentProject: (id: string | null, code: string | null) => void;
}
const getInitialProjectId = () => {
@@ -12,16 +14,41 @@ const getInitialProjectId = () => {
return localStorage.getItem("active_project");
};
const getInitialProjectCode = () => {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem("NETWORK_NAME");
};
const persistProjectId = (id: string | null) => {
if (typeof window === "undefined") return;
if (id) {
localStorage.setItem("active_project", id);
} else {
localStorage.removeItem("active_project");
}
};
const persistProjectCode = (code: string | null) => {
if (typeof window === "undefined") return;
if (code) {
localStorage.setItem("NETWORK_NAME", code);
} else {
localStorage.removeItem("NETWORK_NAME");
}
};
export const useProjectStore = create<ProjectState>((set) => ({
currentProjectId: getInitialProjectId(),
currentProjectCode: getInitialProjectCode(),
setCurrentProjectId: (id) => {
if (typeof window !== "undefined") {
if (id) {
localStorage.setItem("active_project", id);
} else {
localStorage.removeItem("active_project");
}
}
persistProjectId(id);
set({ currentProjectId: id });
},
setCurrentProject: (id, code) => {
persistProjectId(id);
persistProjectCode(code);
set({ currentProjectId: id, currentProjectCode: code });
},
}));