Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
617 lines
28 KiB
TypeScript
617 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import MenuItem from "@mui/material/MenuItem";
|
||
import Select from "@mui/material/Select";
|
||
import clsx from "clsx";
|
||
import { useId, useState, type ReactNode } from "react";
|
||
import {
|
||
FiBox,
|
||
FiCamera,
|
||
FiCheck,
|
||
FiChevronDown,
|
||
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";
|
||
const selectSx = {
|
||
height: 40,
|
||
borderRadius: "0.5rem",
|
||
backgroundColor: "rgba(255,255,255,0.55)",
|
||
color: "#1e293b",
|
||
fontSize: "0.875rem",
|
||
transition: "background-color 150ms ease, box-shadow 150ms ease",
|
||
"&:hover": {
|
||
backgroundColor: "rgba(255,255,255,0.78)",
|
||
},
|
||
"& .MuiSelect-select": {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
minHeight: "0 !important",
|
||
padding: "8px 38px 8px 12px !important",
|
||
},
|
||
"& .MuiSelect-icon": {
|
||
right: 12,
|
||
color: "#64748b",
|
||
fontSize: 16,
|
||
transition: "transform 150ms ease",
|
||
},
|
||
"& .MuiOutlinedInput-notchedOutline": {
|
||
borderColor: "rgba(148,163,184,0.7)",
|
||
},
|
||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||
borderColor: "rgba(59,130,246,0.65)",
|
||
},
|
||
"&.Mui-focused": {
|
||
backgroundColor: "rgba(255,255,255,0.82)",
|
||
boxShadow: "0 0 0 3px rgba(59,130,246,0.16)",
|
||
},
|
||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||
borderColor: "#3b82f6",
|
||
borderWidth: 1,
|
||
},
|
||
};
|
||
const selectMenuProps = {
|
||
disableScrollLock: true,
|
||
PaperProps: {
|
||
elevation: 0,
|
||
sx: {
|
||
mt: 0.75,
|
||
overflow: "hidden",
|
||
borderRadius: "0.75rem",
|
||
border: "1px solid rgba(255,255,255,0.72)",
|
||
background:
|
||
"linear-gradient(135deg, rgba(244,249,252,0.94), rgba(225,239,248,0.88))",
|
||
backdropFilter: "blur(24px) saturate(155%)",
|
||
WebkitBackdropFilter: "blur(24px) saturate(155%)",
|
||
boxShadow:
|
||
"inset 0 1px 0 rgba(255,255,255,0.92), 0 16px 42px rgba(15,43,69,0.22)",
|
||
},
|
||
},
|
||
MenuListProps: {
|
||
sx: {
|
||
p: 0.75,
|
||
},
|
||
},
|
||
};
|
||
const selectMenuItemSx = {
|
||
minHeight: 40,
|
||
gap: 1,
|
||
borderRadius: "0.5rem",
|
||
px: 1.25,
|
||
color: "#334155",
|
||
fontSize: "0.875rem",
|
||
transition: "background-color 120ms ease, color 120ms ease",
|
||
"&:hover": {
|
||
backgroundColor: "rgba(255,255,255,0.68)",
|
||
color: "#1d4ed8",
|
||
},
|
||
"&.Mui-selected": {
|
||
backgroundColor: "#2563eb",
|
||
color: "#fff",
|
||
},
|
||
"&.Mui-selected:hover": {
|
||
backgroundColor: "#1d4ed8",
|
||
},
|
||
"&.Mui-focusVisible": {
|
||
outline: "2px solid rgba(59,130,246,0.42)",
|
||
outlineOffset: -2,
|
||
},
|
||
};
|
||
|
||
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) => {
|
||
if (open && activeTab === tab) {
|
||
onOpenChange(false);
|
||
return;
|
||
}
|
||
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 h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-auto md:right-4 md:top-4 md:h-[min(760px,calc(100%-2rem))] 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 [overflow-anchor:none] [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" })} />
|
||
<LabeledSlider
|
||
label={`建筑背景透明度 ${Math.round((1 - state.appearance.contextOpacity) * 100)}%`}
|
||
value={1 - state.appearance.contextOpacity}
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
disabled={!state.contextVisible}
|
||
onChange={(value) => onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })}
|
||
/>
|
||
<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 }) {
|
||
const labelId = useId();
|
||
const selected = options.find((option) => option.value === value);
|
||
|
||
return (
|
||
<div className="block">
|
||
<span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
|
||
<Select
|
||
fullWidth
|
||
labelId={labelId}
|
||
value={value}
|
||
renderValue={() => selected?.label ?? value}
|
||
MenuProps={selectMenuProps}
|
||
IconComponent={FiChevronDown}
|
||
onChange={(event) => onChange(String(event.target.value))}
|
||
sx={selectSx}
|
||
>
|
||
{options.map((option) => {
|
||
const optionSelected = option.value === value;
|
||
return (
|
||
<MenuItem key={option.value} value={option.value} sx={selectMenuItemSx}>
|
||
<span>{option.label}</span>
|
||
{optionSelected && <FiCheck aria-hidden="true" className="ml-auto text-base" />}
|
||
</MenuItem>
|
||
);
|
||
})}
|
||
</Select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className="flex min-h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||
>
|
||
<span>{label}</span>
|
||
<span
|
||
aria-hidden="true"
|
||
className={clsx(
|
||
"relative h-6 w-11 shrink-0 rounded-full transition-colors 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",
|
||
checked
|
||
? "bg-blue-600 after:translate-x-5"
|
||
: "bg-slate-300/80",
|
||
)}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function LabeledSlider({ label, value, min, max, step, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||
return (
|
||
<label className={clsx("block", disabled && "opacity-45")}>
|
||
<span className="mb-1.5 flex items-center justify-between 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 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} 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>
|
||
);
|
||
}
|