feat: refine workbench visuals and map controls

Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronDown, Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import {
LAYER_GROUP_GEOMETRIES,
@@ -13,7 +14,10 @@ import type {
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, type AvailableWaterNetworkSourceId } from "../map/sources";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
type MapDevPanelProps = {
@@ -33,21 +37,80 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
const LABEL_PRESETS: Record<
LayerGroupId,
{
property: string;
precision: number;
unit: string;
options: { value: string; label: string }[];
}
> = {
pipes: {
property: "diameter",
precision: 0,
unit: "mm",
options: [
{ value: "diameter", label: "管径 diameter" },
{ value: "length", label: "长度 length" },
{ value: "roughness", label: "粗糙系数 roughness" },
{ value: "minor_loss", label: "局部损失 minor_loss" }
]
},
junctions: {
property: "elevation",
precision: 1,
unit: "m",
options: [
{ value: "elevation", label: "高程 elevation" },
{ value: "demand", label: "需水量 demand" }
]
},
valves: {
property: "diameter",
precision: 0,
unit: "mm",
options: [
{ value: "diameter", label: "管径 diameter" },
{ value: "setting", label: "设定值 setting" },
{ value: "minor_loss", label: "局部损失 minor_loss" }
]
},
reservoirs: {
property: "head",
precision: 1,
unit: "m",
options: [{ value: "head", label: "水头 head" }]
}
};
const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围"
};
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
export function MapDevPanel({
commands,
controllerState,
detailFeature,
onClose
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
const [featureId, setFeatureId] = useState("440681");
const [property, setProperty] = useState("diameter");
const [flowPipeId, setFlowPipeId] = useState("440681");
const [flowVelocity, setFlowVelocity] = useState("-1");
const [precision, setPrecision] = useState(0);
const [unit, setUnit] = useState("mm");
const [groupId, setGroupId] = useState<LayerGroupId>("pipes");
const [showValueLabels, setShowValueLabels] = useState(true);
const [showFeatureIds, setShowFeatureIds] = useState(false);
const [labelSize, setLabelSize] = useState(12);
const [labelColor, setLabelColor] = useState<string>(MAP_STYLE_TOKENS.label.primary);
const [draft, setDraft] = useState<Record<string, string | number>>({
color: MAP_STYLE_TOKENS.supply.pipeMedium,
fillColor: MAP_STYLE_TOKENS.supply.junction,
@@ -68,26 +131,49 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const numericProperties = useMemo(
() => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId
? getNumericFeatureProperties(detailFeature.properties)
: [],
[detailFeature, featureId, sourceId]
);
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
function selectStyleGroup(nextGroupId: LayerGroupId) {
const preset = LABEL_PRESETS[nextGroupId];
const saved = controllerState.valueLabels[nextGroupId];
setGroupId(nextGroupId);
setProperty(saved?.property ?? preset.property);
setPrecision(saved?.precision ?? preset.precision);
setUnit(saved?.unit ?? preset.unit);
setShowValueLabels(saved?.showValue ?? true);
setShowFeatureIds(saved?.showId ?? false);
setLabelSize(saved?.textSize ?? 12);
setLabelColor(saved?.textColor ?? MAP_STYLE_TOKENS.label.primary);
}
function useSelectedFeature() {
if (!detailFeature) return;
if (AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)) {
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
if (firstNumeric) setProperty(firstNumeric[0]);
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId;
selectStyleGroup(nextGroupId);
const allowedProperties = new Set(
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
([name]) => allowedProperties.has(name)
);
if (firstNumeric) setProperty(firstNumeric[0]);
}
}
function applyDraft() {
function applyLayerStyle() {
let patch: LayerGroupStylePatch;
if (geometry === "line") {
patch = {
@@ -103,15 +189,44 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
opacity: Number(draft.opacity),
radiusMultiplier: Number(draft.radiusMultiplier),
strokeMultiplier: Number(draft.strokeMultiplier),
iconMultiplier: Number(draft.iconMultiplier)
};
}
commands.applyLayerGroupStyle(groupId, patch);
}
function applyLabels() {
if (showValueLabels || showFeatureIds) {
void commands.showValueLabel({
sourceId: groupId,
property,
precision,
unit,
showValue: showValueLabels,
showId: showFeatureIds,
textSize: labelSize,
textColor: labelColor
});
} else {
commands.clearValueLabel(groupId);
}
}
function applyFlowCalculation() {
commands.updatePipeFlowCalculation({
pipeId: flowPipeId.trim(),
velocity: Number(flowVelocity)
});
}
const flowSummary = controllerState.flowSummary;
const validFlowCalculation =
flowPipeId.trim().length > 0 && Number.isFinite(Number(flowVelocity));
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
@@ -119,14 +234,23 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p>
</div>
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
<Button
size="icon"
variant="ghost"
type="button"
aria-label="关闭 Dev Panel"
onClick={onClose}
>
<X size={16} />
</button>
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{controllerState.errorCode ? (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
<div
role="alert"
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
@@ -134,101 +258,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
<Field label="业务源">
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as AvailableWaterNetworkSourceId)} className={inputClassName}>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={sourceId}
onChange={(event) => {
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
className={inputClassName}
>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
</Field>
<Field label="Feature ID">
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
<input
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到 Feature
</Button>
</div>
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.target ? (
<StatusText>
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
</StatusText>
) : null}
</PanelSection>
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
<PanelSection
title="全网水流"
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || !target.featureId}
onClick={() => setFlowPipeId(target.featureId)}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后自动显示全网 mock 流向</StatusText>
)}
</PanelSection>
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
<Field label="数值属性">
{numericProperties.length ? (
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
</select>
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
</div>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
</div>
</PanelSection>
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
>
<Field label="业务图层组">
<div className="relative">
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={groupId}
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
className={cn(inputClassName, "appearance-none pr-8")}
>
{STYLE_GROUP_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
<ChevronDown
size={14}
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
/>
</div>
</Field>
{geometry === "line" ? (
<>
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<Field label="线型">
<select
value={draft.dash}
onChange={(event) =>
setDraft((current) => ({ ...current, dash: event.target.value }))
}
className={inputClassName}
>
<option value="original">原始</option>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
</select>
</Field>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.iconMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<Field label="数值属性">
<select
value={property}
onChange={(event) => setProperty(event.target.value)}
className={inputClassName}
>
{LABEL_PRESETS[groupId].options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-slate-200/70 p-3">
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
<Button
type="button"
onClick={() => commands.resetAll()}
className="w-full text-xs"
variant="outline"
>
<RotateCcw size={14} />
重置全部
</Button>
</div>
</aside>
);
}
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
function PanelSection({
title,
description,
children
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
</div>
{children}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
{children}
</label>
);
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
function ToggleField({
label,
checked,
onChange
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-blue-600"
/>
{label}
</label>
);
}
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
function ColorField({
label,
value,
onChange
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
/>
<span className="text-xs font-medium text-slate-600">{value}</span>
</div>
</Field>
);
}
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
function RangeField({
label,
min,
max,
step,
value,
onChange
}: {
label: string;
min: number;
max: number;
step: number;
value: number;
onChange: (value: number) => void;
}) {
return (
<Field label={`${label} · ${value}`}>
<input
aria-label={label}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-blue-600"
/>
</Field>
);
}
function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
>
{children}
</p>
);
}
const inputClassName =
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";