Files
TJWaterFrontend_Refine/src/components/threeDimensional/ThreeDimensionalControls.tsx
T
jiang a9b25b94d8
Generic Container CI/CD / test-build-publish (push) Successful in 2m20s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m20s
feat(3d): refine scene controls and camera views
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.
2026-09-14 15:41:44 +08:00

617 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}