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:
@@ -439,7 +439,10 @@ function TickerProgressBar({
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.span
|
||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||
className={cn(
|
||||
"status-tone-info block h-full rounded-full",
|
||||
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
|
||||
)}
|
||||
animate={{ width: progressLabel }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
@@ -452,9 +455,9 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||
const dashOffset = 44 - (44 * progress) / 100;
|
||||
return (
|
||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<span className="status-tone-info grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-[var(--status-border)]" />
|
||||
<motion.circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
@@ -462,13 +465,13 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="stroke-blue-600"
|
||||
className="stroke-[var(--status-foreground)]"
|
||||
strokeDasharray="44"
|
||||
animate={{ strokeDashoffset: dashOffset }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-[var(--status-mark)]" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||
<section className="material-tone-agent mt-4 rounded border border-violet-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-violet-800">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
showMapNotice
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
@@ -48,21 +53,13 @@ const FEATURE_LAYER_META: Record<
|
||||
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
|
||||
};
|
||||
|
||||
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-50 text-emerald-700 ring-emerald-600/15",
|
||||
inactive: "bg-slate-100 text-slate-600 ring-slate-500/15",
|
||||
warning: "bg-orange-50 text-orange-700 ring-orange-600/15",
|
||||
critical: "bg-red-50 text-red-700 ring-red-600/15",
|
||||
info: "bg-blue-50 text-blue-700 ring-blue-600/15"
|
||||
};
|
||||
|
||||
const BADGE_DOT_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-500",
|
||||
inactive: "bg-slate-400",
|
||||
warning: "bg-orange-500",
|
||||
critical: "bg-red-500",
|
||||
info: "bg-blue-500"
|
||||
};
|
||||
const badgeTones = {
|
||||
active: "success",
|
||||
inactive: "neutral",
|
||||
warning: "warning",
|
||||
critical: "danger",
|
||||
info: "info"
|
||||
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
|
||||
|
||||
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
@@ -125,10 +122,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
||||
{panelModel.badge ? (
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset", BADGE_CLASS_NAMES[panelModel.badge.tone])}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])} aria-hidden="true" />
|
||||
<StatusBadge
|
||||
tone={badgeTones[panelModel.badge.tone]}
|
||||
icon={<StatusDot tone={badgeTones[panelModel.badge.tone]} />}
|
||||
>
|
||||
{panelModel.badge.label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||
@@ -137,12 +136,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{canCopy ? (
|
||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
|
||||
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
<Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MapDevPanel } from "./map-dev-panel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("MapDevPanel flow controls", () => {
|
||||
it("updates a pipe with its signed velocity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
|
||||
render(
|
||||
<MapDevPanel
|
||||
commands={commands}
|
||||
controllerState={createControllerState()}
|
||||
detailFeature={null}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const pipeId = screen.getByLabelText("管道 ID");
|
||||
const velocity = screen.getByLabelText("有符号流速");
|
||||
await user.clear(pipeId);
|
||||
await user.type(pipeId, "P-9");
|
||||
await user.clear(velocity);
|
||||
await user.type(velocity, "-2.75");
|
||||
await user.click(screen.getByRole("button", { name: "更新该管道方向" }));
|
||||
|
||||
expect(commands.updatePipeFlowCalculation).toHaveBeenCalledWith({
|
||||
pipeId: "P-9",
|
||||
velocity: -2.75
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the current flow direction counts", () => {
|
||||
render(
|
||||
<MapDevPanel
|
||||
commands={createCommands()}
|
||||
controllerState={createControllerState({
|
||||
flowSummary: {
|
||||
total: 4,
|
||||
forward: 2,
|
||||
reverse: 1,
|
||||
stopped: 1,
|
||||
invalid: 0
|
||||
}
|
||||
})}
|
||||
detailFeature={null}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", {
|
||||
name: ""
|
||||
})).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
|
||||
});
|
||||
});
|
||||
|
||||
function createCommands() {
|
||||
return {
|
||||
zoomToFeature: vi.fn(),
|
||||
locateAndHighlight: vi.fn(),
|
||||
highlight: vi.fn(),
|
||||
clearHighlight: vi.fn(),
|
||||
setFlowVisible: vi.fn(),
|
||||
replacePipeFlowCalculations: vi.fn(),
|
||||
updatePipeFlowCalculation: vi.fn(),
|
||||
clearPipeFlowCalculations: vi.fn(),
|
||||
showValueLabel: vi.fn(),
|
||||
clearValueLabel: vi.fn(),
|
||||
applyLayerGroupStyle: vi.fn(),
|
||||
resetLayerGroupStyle: vi.fn(),
|
||||
renderScadaAnalysis: vi.fn(),
|
||||
clearScadaAnalysis: vi.fn(),
|
||||
resetAll: vi.fn()
|
||||
} satisfies WorkbenchMapCommands;
|
||||
}
|
||||
|
||||
function createControllerState(
|
||||
patch: Partial<WorkbenchMapControllerState> = {}
|
||||
): WorkbenchMapControllerState {
|
||||
return {
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: {
|
||||
total: 0,
|
||||
forward: 0,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid: 0
|
||||
},
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -149,7 +149,7 @@ export function MobileWorkbenchSheet({
|
||||
)}
|
||||
style={{ height: dragHeight ?? restingHeight }}
|
||||
>
|
||||
<div className="flex h-7 shrink-0 items-center">
|
||||
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
getRunningWorkflowDefinition,
|
||||
@@ -37,25 +42,26 @@ import {
|
||||
formatDuration,
|
||||
formatTime,
|
||||
formatTimeRange,
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getRiskLabel,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStagePillClassName,
|
||||
statusClassNames,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
statusLabels
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||
|
||||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
||||
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||
|
||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
attention: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
critical: "border-red-200 bg-red-50 text-red-700"
|
||||
};
|
||||
const riskTones = {
|
||||
normal: "success",
|
||||
attention: "warning",
|
||||
critical: "danger"
|
||||
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
||||
|
||||
type IncidentOperation = {
|
||||
target: string;
|
||||
@@ -106,15 +112,21 @@ export function StatusPill({
|
||||
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
STATUS_PILL_CLASS_NAME,
|
||||
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
|
||||
className
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageTone(currentStage?.id)
|
||||
: getStatusTone(condition.status)
|
||||
}
|
||||
activity={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageActivity(currentStage?.id)
|
||||
: getStatusActivity(condition.status)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="min-h-0"
|
||||
className="relative h-full min-h-0"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
@@ -199,7 +211,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={ShieldAlert}
|
||||
title="异常判断"
|
||||
iconClassName="text-red-600"
|
||||
iconClassName="status-tone-danger text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -222,7 +234,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={Lightbulb}
|
||||
title="关注说明"
|
||||
iconClassName="text-orange-600"
|
||||
iconClassName="status-tone-warning text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -332,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
NEUTRAL_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -344,9 +366,7 @@ type DetailShellProps = {
|
||||
|
||||
function DetailShell({ children }: DetailShellProps) {
|
||||
return (
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,7 +541,9 @@ function IncidentWorkOrderPanel({
|
||||
className={cn(
|
||||
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
||||
"rounded-xl",
|
||||
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
||||
submitted
|
||||
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
||||
@@ -544,9 +566,9 @@ function IncidentOperationList({
|
||||
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionEyebrow icon={Wrench} label={operation.action} />
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
|
||||
<StatusBadge tone={submitted ? "success" : "neutral"}>
|
||||
{submitted ? "已通知" : operation.elementType}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
||||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||||
@@ -646,10 +668,9 @@ function ConditionStatusReport({
|
||||
|
||||
function LifecyclePill() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
|
||||
<CheckCircle2 size={12} aria-hidden="true" />
|
||||
<span>完成</span>
|
||||
</span>
|
||||
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
|
||||
完成
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,23 +679,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
|
||||
const label = getRiskLabel(riskLevel);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
|
||||
getRiskIndicatorClassName(riskLevel)
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={riskTones[riskLevel]}
|
||||
icon={<Icon size={12} />}
|
||||
aria-label={`风险等级:${label}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
return riskIndicatorClassNames[riskLevel];
|
||||
}
|
||||
|
||||
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={REPORT_META_TILE_CLASS_NAME}>
|
||||
@@ -710,7 +724,7 @@ function ReportSection({
|
||||
|
||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
return (
|
||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||
@@ -721,9 +735,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
|
||||
<StatusBadge tone={riskTones[kpi.status]}>
|
||||
{getRiskLabel(kpi.status)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
||||
{kpi.threshold || kpi.baseline ? (
|
||||
@@ -736,18 +750,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
if (riskLevel === "critical") {
|
||||
return "border-red-200/80";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "border-orange-200/80";
|
||||
}
|
||||
|
||||
return "border-slate-200/70";
|
||||
}
|
||||
|
||||
function DecisionActionStrip({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -807,64 +809,36 @@ function SectionEyebrow({
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
|
||||
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
|
||||
if (tone === "completed") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (tone === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
if (tone === "active") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||||
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
|
||||
if (status === "done") {
|
||||
return "bg-green-500 text-white";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "bg-blue-600 text-white";
|
||||
return "info";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "bg-orange-500 text-white";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "bg-slate-100 text-slate-400";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||||
if (status === "current") {
|
||||
return "surface-reading border-blue-100";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "surface-reading border-orange-100";
|
||||
}
|
||||
|
||||
return "surface-reading border-slate-200/70";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||||
if (status === "done") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-white text-slate-500";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
||||
@@ -932,7 +906,6 @@ function RunningWorkflowPanel({
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={Loader2}
|
||||
title={workflow.title}
|
||||
badge="执行中"
|
||||
badgeTone="active"
|
||||
@@ -988,7 +961,7 @@ function TaskExecutionPanel({
|
||||
badgeTone = "pending",
|
||||
children
|
||||
}: {
|
||||
icon: typeof Bot;
|
||||
icon?: typeof Bot;
|
||||
title: string;
|
||||
badge: string;
|
||||
summary?: string;
|
||||
@@ -1001,10 +974,17 @@ function TaskExecutionPanel({
|
||||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||||
{Icon ? (
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{title}</span>
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={getTaskExecutionBadgeTone(badgeTone)}
|
||||
activity={badgeTone === "active" ? "live" : "static"}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||||
{meta?.length ? (
|
||||
@@ -1043,29 +1023,32 @@ function TaskExecutionStepRow({
|
||||
{!isLast ? (
|
||||
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
|
||||
<SemanticStatusIcon
|
||||
tone={getTaskExecutionStepTone(step.status)}
|
||||
activity={step.status === "current" ? "loading" : "static"}
|
||||
shape="circle"
|
||||
size="md"
|
||||
className="relative z-10 ring-4 ring-white"
|
||||
>
|
||||
{step.status === "done" ? (
|
||||
<CheckCircle2 size={14} aria-hidden="true" />
|
||||
<CheckCircle2 size={14} />
|
||||
) : step.status === "current" ? (
|
||||
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
|
||||
<Loader2 size={14} />
|
||||
) : step.status === "confirmation" ? (
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<AlertTriangle size={14} />
|
||||
) : (
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<Clock3 size={14} />
|
||||
)}
|
||||
</span>
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border px-2.5 py-2",
|
||||
getTaskExecutionStepSurfaceClassName(step.status)
|
||||
)}
|
||||
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
|
||||
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
|
||||
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
{step.meta?.length ? (
|
||||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
describe("scheduled condition status presentation", () => {
|
||||
it("maps lifecycle states to the shared semantic language", () => {
|
||||
expect({
|
||||
pending: getStatusTone("pending"),
|
||||
running: getStatusTone("running"),
|
||||
awaitingReply: getStatusTone("awaiting_reply"),
|
||||
completed: getStatusTone("completed"),
|
||||
warning: getStatusTone("warning"),
|
||||
error: getStatusTone("error")
|
||||
}).toEqual({
|
||||
pending: "neutral",
|
||||
running: "info",
|
||||
awaitingReply: "warning",
|
||||
completed: "success",
|
||||
warning: "warning",
|
||||
error: "danger"
|
||||
});
|
||||
expect(getStatusActivity("running")).toBe("loading");
|
||||
expect(getStatusActivity("awaiting_reply")).toBe("static");
|
||||
});
|
||||
|
||||
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
|
||||
expect({
|
||||
dispatch: getWorkOrderStageTone("dispatch"),
|
||||
execution: getWorkOrderStageTone("execution"),
|
||||
reply: getWorkOrderStageTone("reply")
|
||||
}).toEqual({
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
});
|
||||
expect(getWorkOrderStageActivity("execution")).toBe("loading");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionItemStatus,
|
||||
@@ -23,14 +24,23 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
awaiting_reply: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
error: "border-red-100 bg-red-50 text-red-700",
|
||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
};
|
||||
const statusTones = {
|
||||
completed: "success",
|
||||
running: "info",
|
||||
awaiting_reply: "warning",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
pending: "neutral"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
|
||||
|
||||
const statusActivities = {
|
||||
completed: "static",
|
||||
running: "loading",
|
||||
awaiting_reply: "static",
|
||||
warning: "static",
|
||||
error: "static",
|
||||
pending: "static"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
|
||||
|
||||
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
@@ -41,14 +51,17 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
awaiting_reply: "bg-orange-50 text-orange-700",
|
||||
warning: "bg-orange-50 text-orange-700",
|
||||
error: "bg-red-50 text-red-700",
|
||||
pending: "bg-slate-100 text-slate-600"
|
||||
};
|
||||
const workOrderStageTones = {
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
|
||||
|
||||
const workOrderStageActivities = {
|
||||
dispatch: "static",
|
||||
execution: "loading",
|
||||
reply: "static"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
|
||||
"running",
|
||||
@@ -165,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionItemStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionItemStatus) {
|
||||
return statusIconClassNames[status];
|
||||
export function getStatusTone(status: ScheduledConditionItemStatus) {
|
||||
return statusTones[status];
|
||||
}
|
||||
|
||||
export function getStatusActivity(status: ScheduledConditionItemStatus) {
|
||||
return statusActivities[status];
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
|
||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||
import {
|
||||
CONDITION_FILTER_ALL,
|
||||
@@ -28,8 +29,12 @@ import {
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusActivity,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
getStatusTone,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
@@ -286,7 +291,12 @@ export function ScheduledConditionFeed({
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? "max-h-[calc(100dvh-8rem)] rounded-2xl"
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
@@ -334,11 +344,9 @@ export function ScheduledConditionFeed({
|
||||
aria-label="关闭工况任务"
|
||||
title="关闭工况任务"
|
||||
onClick={onClose}
|
||||
className="group grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-500"
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<span className="surface-control grid h-9 w-9 place-items-center rounded-full shadow-[inset_0_0_0_1px_rgba(148,163,184,0.26),0_4px_12px_rgba(15,33,55,0.10)] transition-[color,background-color,scale] group-active:scale-95 [@media(hover:hover)]:group-hover:bg-blue-50 [@media(hover:hover)]:group-hover:text-blue-700">
|
||||
<X size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -349,19 +357,19 @@ export function ScheduledConditionFeed({
|
||||
mobileSheet
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded"
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter"
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
@@ -459,7 +467,7 @@ function ConditionTimelinePanel({
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-[calc(100dvh-8.75rem)] max-h-[calc(100dvh-8.75rem)]"
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -486,7 +494,7 @@ function ConditionTimelinePanel({
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
selectedConditionId={selectedConditionId}
|
||||
@@ -583,7 +591,7 @@ function ConditionFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
@@ -722,10 +730,7 @@ function ConditionDetailSkeleton() {
|
||||
return (
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
className="relative min-h-[420px] overflow-hidden px-4 py-3"
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
@@ -760,8 +765,18 @@ function ConditionDetailSkeleton() {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -830,8 +845,15 @@ function ConditionTimelineRow({
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
const Icon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
|
||||
const tone = isWorkOrder
|
||||
? getWorkOrderStageTone(workOrderStage)
|
||||
: getStatusTone(condition.status);
|
||||
const activity = isWorkOrder
|
||||
? getWorkOrderStageActivity(workOrderStage)
|
||||
: getStatusActivity(condition.status);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -855,22 +877,18 @@ function ConditionTimelineRow({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
<SemanticStatusIcon
|
||||
tone={tone}
|
||||
activity={activity}
|
||||
shape="circle"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
||||
selected
|
||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
||||
: isWorkOrder
|
||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
||||
: getStatusIconClassName(condition.status)
|
||||
"relative z-10 ring-4 ring-white",
|
||||
selected && "group-focus-visible:ring-blue-100"
|
||||
)}
|
||||
>
|
||||
<StatusIcon
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={condition.status === "running" ? "animate-spin" : undefined}
|
||||
/>
|
||||
</span>
|
||||
<Icon size={12} />
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import {
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
@@ -13,6 +10,7 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
conditionExpanded: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
@@ -25,6 +23,7 @@ export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
conditionExpanded,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
@@ -32,8 +31,8 @@ export function WorkbenchAgentPanels({
|
||||
onExpandPanel,
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth);
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,8 +42,8 @@ export function WorkbenchAgentPanels({
|
||||
return (
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[620px] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
||||
style={{ width: resize.width ?? defaultWidth }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
@@ -57,7 +56,7 @@ export function WorkbenchAgentPanels({
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth)}
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
headerControlIconClassName
|
||||
@@ -47,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const scenarioStatusTones = {
|
||||
active: "info",
|
||||
draft: "neutral",
|
||||
review: "warning"
|
||||
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
@@ -112,9 +125,9 @@ export function ScenarioMenu({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
@@ -161,29 +174,38 @@ export function AlertMenu({
|
||||
className={cn(
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
||||
compact && "h-10 w-10 justify-center px-0"
|
||||
hasAlerts && !compact &&
|
||||
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
|
||||
compact && compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts &&
|
||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
||||
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||
{compact ? (
|
||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
|
||||
!hasAlerts && "border-transparent! bg-transparent!"
|
||||
)}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -199,7 +221,7 @@ export function AlertMenu({
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
||||
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
@@ -207,20 +229,22 @@ export function AlertMenu({
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
||||
<StatusDot tone="danger" size="md" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
<CheckCircle2 size={16} />
|
||||
</SemanticStatusIcon>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||
</div>
|
||||
@@ -274,7 +298,11 @@ export function UserMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
className={cn(
|
||||
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||
headerControlButtonClassName,
|
||||
compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label="打开用户菜单"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
@@ -316,15 +344,13 @@ function AlertQueueSummary({
|
||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={hasAlerts ? "danger" : "success"}
|
||||
size="md"
|
||||
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
|
||||
</SemanticStatusIcon>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
@@ -332,17 +358,12 @@ function AlertQueueSummary({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
||||
)}
|
||||
>
|
||||
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
||||
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
|
||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,9 +448,9 @@ function MenuHeader({
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
|
||||
</div>
|
||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -469,7 +490,7 @@ function MenuAction({
|
||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
||||
tone === "warning" && "status-icon status-tone-warning",
|
||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
export const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
cn(
|
||||
buttonInteractionVariants({ tone: "neutral" }),
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
|
||||
);
|
||||
|
||||
export const compactHeaderControlButtonClassName =
|
||||
"h-10 w-10 justify-center px-0";
|
||||
|
||||
export const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||
|
||||
@@ -138,26 +138,18 @@ export function WorkbenchTopBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
data-selection-tone="soft"
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn(
|
||||
"group px-2 active:scale-95",
|
||||
headerControlButtonClassName,
|
||||
devPanelOpen && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
devPanelOpen && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
@@ -210,15 +202,12 @@ function TaskTickerToggle({
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -232,15 +221,12 @@ function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<span className={headerControlIconClassName}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
|
||||
Reference in New Issue
Block a user