feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -22,6 +22,7 @@ import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
import type {
OperationalTask,
@@ -542,7 +543,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
<DateTimeField label="预约时间" value={toLocalDateTime(draft.scheduled_at)} onChange={(value) => setDraft({ ...draft, scheduled_at: value ? new Date(value).toISOString() : null })} disabled={submitting !== null} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null}
{action.evidence_refs.length > 0 ? (
@@ -0,0 +1,42 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeatureLocationPanel } from "./feature-location-panel";
afterEach(cleanup);
const state = { pending: false, targets: [], targetResult: null, errorCode: null };
const createCommands = () => ({ highlight: vi.fn(), zoomToFeature: vi.fn(), locateAndHighlight: vi.fn(), clearHighlight: vi.fn() });
describe("FeatureLocationPanel", () => {
it("uses the selected feature's source and ID together", async () => {
const user = userEvent.setup();
const commands = createCommands();
const onUseSelectedFeature = vi.fn();
render(<FeatureLocationPanel commands={commands} controllerState={state}
detailFeature={{ id: "PRESSURE-17", layer: "scada", title: "压力监测点", subtitle: "", properties: {} }}
onUseSelectedFeature={onUseSelectedFeature} />);
await user.click(screen.getByRole("button", { name: "使用当前地图选中" }));
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("PRESSURE-17");
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
expect(commands.locateAndHighlight).toHaveBeenCalledWith({ sourceId: "scada", featureIds: ["PRESSURE-17"] });
expect(onUseSelectedFeature).toHaveBeenCalledOnce();
});
it("keeps map actions disabled until the map is ready and while a request is pending", async () => {
const user = userEvent.setup();
const commands = createCommands();
const props = { commands, controllerState: state, detailFeature: null };
const { rerender } = render(<FeatureLocationPanel {...props} enabled={false} />);
await user.type(screen.getByLabelText("要素 ID(可多个)"), "P-17");
const action = () => screen.getByRole("button", { name: "定位并高亮" });
expect(action()).toBeDisabled();
rerender(<FeatureLocationPanel {...props} enabled />);
expect(action()).toBeEnabled();
rerender(<FeatureLocationPanel {...props} controllerState={{ ...state, pending: true }} enabled />);
expect(screen.getByRole("button", { name: "正在定位要素" })).toBeDisabled();
expect(screen.getByRole("button", { name: "仅高亮" })).toBeDisabled();
expect(screen.getByRole("button", { name: "缩放到要素" })).toBeDisabled();
expect(commands.locateAndHighlight).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,102 @@
import { Crosshair, LocateFixed, MousePointer2, Trash2, ZoomIn } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/shared/ui/button";
import { FieldSelect } from "@/shared/ui/field-select";
import { Textarea } from "@/shared/ui/textarea";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, type AvailableWaterNetworkSourceId } from "../map/sources";
import type { FeatureBatchTarget, WorkbenchMapCommands, WorkbenchMapControllerState } from "../map/workbench-map-controller";
import type { DetailFeature } from "../types";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
type FeatureLocationDraft = {
sourceId: AvailableWaterNetworkSourceId;
featureId: string;
};
export type FeatureLocationPanelProps = {
commands: Pick<WorkbenchMapCommands, "highlight" | "zoomToFeature" | "locateAndHighlight" | "clearHighlight">;
controllerState: Pick<WorkbenchMapControllerState, "pending" | "targets" | "targetResult" | "errorCode">;
detailFeature: DetailFeature | null;
enabled?: boolean;
value?: FeatureLocationDraft;
onValueChange?: (value: FeatureLocationDraft) => void;
onUseSelectedFeature?: () => void;
showError?: boolean;
contentClassName?: string;
};
export function FeatureLocationPanel({ commands, controllerState, detailFeature, enabled = true,
value, onValueChange, onUseSelectedFeature, showError = true, contentClassName }: FeatureLocationPanelProps) {
const inputId = useId();
const helpId = useId();
const [draft, setDraft] = useState<FeatureLocationDraft>({ sourceId: "pipes", featureId: "" });
const { sourceId, featureId } = value ?? draft;
function updateDraft(next: FeatureLocationDraft) {
if (!value) setDraft(next);
onValueChange?.(next);
}
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = enabled && featureIds.length > 0 && !invalidIds && !controllerState.pending;
const canUseSelected = Boolean(detailFeature && AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId));
const target: FeatureBatchTarget = { sourceId, featureIds };
const result = controllerState.targetResult;
return (
<div className="space-y-3">
<FieldSelect label="业务源" value={sourceId} contentClassName={contentClassName}
onValueChange={(nextSourceId) => updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })}
options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label htmlFor={inputId} className="text-xs font-medium text-[var(--ink-muted)]">要素 ID(可多个)</label>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{featureIds.length} / {MAX_MAP_FEATURE_IDS}</span>
</div>
<Textarea id={inputId} value={featureId} rows={3} placeholder="粘贴要素 ID,支持多个"
onChange={(event) => updateDraft({ sourceId, featureId: event.target.value })} aria-describedby={helpId} aria-invalid={invalidIds}
className="min-h-24 resize-y border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
/>
<p id={helpId} className="text-[11px] leading-5 text-[var(--ink-muted)]">逗号、空格或换行分隔,自动去重。</p>
</div>
{invalidIds ? <p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
<Button type="button" variant="secondary" className="w-full text-xs" disabled={!canUseSelected}
onClick={() => {
if (!detailFeature || !canUseSelected) return;
updateDraft({ sourceId: detailFeature.layer as AvailableWaterNetworkSourceId, featureId: detailFeature.id });
onUseSelectedFeature?.();
}}>
<MousePointer2 aria-hidden="true" />使用当前地图选中
</Button>
<div className="space-y-2 border-t border-[var(--surface-divider)] pt-3">
<Button type="button" className="w-full text-xs" disabled={!canLocate}
loading={controllerState.pending} loadingLabel="正在定位要素"
onClick={() => void commands.locateAndHighlight(target)}>
<LocateFixed aria-hidden="true" />定位并高亮
</Button>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.highlight(target)}>
<Crosshair aria-hidden="true" />仅高亮
</Button>
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.zoomToFeature(target)}>
<ZoomIn aria-hidden="true" />缩放到要素
</Button>
</div>
</div>
{!enabled ? <p role="status" className="text-xs text-[var(--ink-muted)]">等待地图加载后即可定位</p> : null}
{showError && controllerState.errorCode && FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode] ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">{FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode]}</p>
) : null}
{result ? <p role="status" className="break-words text-xs leading-5 text-[var(--ink-muted)]">
本次命中 {result.featureIds.length} 个{result.missingIds.length ? `,未找到 ${result.missingIds.length} 个:${result.missingIds.join("、")}` : ",全部找到"}
</p> : null}
<div className="flex items-center justify-between gap-2 border-t border-[var(--surface-divider)] pt-2">
<p className="text-[11px] text-[var(--ink-muted)]">已高亮 {controllerState.targets.length} 个要素</p>
<Button type="button" variant="ghost" className="shrink-0 text-xs" onClick={() => commands.clearHighlight()}>
<Trash2 aria-hidden="true" />清除高亮
</Button>
</div>
</div>
);
}
@@ -137,7 +137,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
))}
</ul> : null}
{result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden", mapResult && "h-auto shrink-0")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className={mapResult ? "space-y-2" : "space-y-3"}>
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
@@ -1,3 +1,4 @@
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -18,8 +19,7 @@ describe("investigation Dev case selection", () => {
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onSample={(id = "combined") => createInvestigationDevCase(id)}
onPreview={onPreview}
onClear={vi.fn()}
/>
@@ -29,7 +29,7 @@ describe("investigation Dev case selection", () => {
await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01"
INVESTIGATION_DEV_NETWORK.pipeIds[0]
);
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
@@ -39,10 +39,10 @@ describe("investigation Dev case selection", () => {
expect.any(String)
);
expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table")),
"候选管段对比"
);
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
expect(screen.getByRole("status")).toHaveTextContent("本地预览完成");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比");
@@ -57,7 +57,6 @@ describe("investigation Dev case selection", () => {
mapReady={false}
charts={0}
overlay={null}
activity={[]}
onSample={vi.fn()}
onPreview={onPreview}
onClear={vi.fn()}
@@ -65,7 +64,7 @@ describe("investigation Dev case selection", () => {
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("data_only"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
@@ -75,7 +74,7 @@ describe("investigation Dev case selection", () => {
expect.any(String)
);
await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("impact_area"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled();
await user.click(execute);
@@ -93,15 +92,14 @@ describe("investigation Dev case selection", () => {
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId)}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
await user.paste(createInvestigationDevCase(id));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
@@ -111,7 +109,7 @@ describe("investigation Dev case selection", () => {
expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("data_only"));
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
@@ -10,12 +10,10 @@ import {
} from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationActionLabels,
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview,
type InvestigationPresentationActivity
type InvestigationDevPreview
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
@@ -23,7 +21,6 @@ export type InvestigationDevToolsProps = {
mapReady: boolean;
charts: number;
overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
@@ -34,7 +31,6 @@ export function InvestigationDevTools({
mapReady,
charts,
overlay,
activity,
onSample,
onPreview,
onClear
@@ -43,11 +39,9 @@ export function InvestigationDevTools({
const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false);
const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
@@ -56,10 +50,8 @@ export function InvestigationDevTools({
setInput(value);
setCaseId(nextCaseId);
setChecked(null);
setPreview(null);
setMessage(null);
setError(null);
setSelectedRecord(0);
}
function clearLoadedPreview() {
if (!previewActive.current) return;
@@ -100,7 +92,6 @@ export function InvestigationDevTools({
}
setRunning(true);
setError(null);
setPreview(parsed);
previewActive.current = true;
try {
await onPreview(parsed, selectedCase?.label ?? "JSONL");
@@ -174,7 +165,7 @@ export function InvestigationDevTools({
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
仅在当前浏览器预览,不写入任务或触发调查。空间位置与要素 ID 以 tjwater_v2 为基准;读数与方案为合成样例,不代表现场事实。
</p>
<FieldSelect
label="JSONL 测试案例"
@@ -197,7 +188,7 @@ export function InvestigationDevTools({
</p>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。
切换即载入,执行时自动校验;已执行的本地预览会清除。定位和高亮请使用“地图调试”页签的业务源与要素 ID。
</p>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -296,7 +287,6 @@ export function InvestigationDevTools({
onClick={() => {
onClear();
previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。");
setError(null);
}}
@@ -319,92 +309,8 @@ export function InvestigationDevTools({
{error}
</p>
) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div>
) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div>
);
}
@@ -18,6 +18,7 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -84,6 +85,7 @@ export function InvestigationPresentationPanel({
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
const reviewing = history?.selectedId != null;
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
@@ -104,7 +106,7 @@ export function InvestigationPresentationPanel({
return (
<motion.aside
layout={immediate ? false : "size"}
layout={immediate || localPreview ? false : "size"}
{...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)}
@@ -112,18 +114,18 @@ export function InvestigationPresentationPanel({
data-testid="investigation-presentation-panel"
data-presentation-scope={scope}
data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg ${localPreview ? `${collapsed ? "h-28" : "h-[min(480px,55dvh)]"} [&>[data-investigation-content]]:flex-1` : ""}`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
>
<motion.header
layout={immediate ? false : "position"}
layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5"
className={`shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
<div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
<h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
<div className={`flex flex-wrap items-center gap-2 ${localPreview ? "col-span-2 row-start-2" : ""}`}>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing
? "历史回看"
@@ -147,18 +149,14 @@ export function InvestigationPresentationPanel({
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
className={`shrink-0 ${localPreview ? "col-start-2 row-start-1" : ""}`}
aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)}
>
<motion.span
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<motion.span className="inline-flex" animate={{ rotate: collapsed ? 180 : 0 }} transition={presentationTransition(immediate)}>
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
@@ -180,12 +178,12 @@ export function InvestigationPresentationPanel({
? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
{history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
<PresentationViewport
key={history?.selectedId ?? "current"}
key="presentation-content"
immediate={immediate}
id={contentId}
>
@@ -306,22 +304,31 @@ export function InvestigationPresentationPanel({
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={item.chart.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-[420px] min-w-0 flex-col gap-3">
<AgentChartView chart={item.chart} embedded expanded />
<div className="shrink-0">{references(item.evidence_refs)}</div>
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
@@ -335,6 +342,7 @@ export function InvestigationPresentationPanel({
blocks={blocks}
records={records}
reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
@@ -1,6 +1,7 @@
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import { formatInvestigationText } from "../utils/investigation-text";
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
task: Pick<OperationalTask, "evidence">;
@@ -56,7 +57,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
{block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{formatInvestigationText(item.block.fallback_text)}</p>}
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> : null}
@@ -1,11 +1,10 @@
import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { RotateCcw, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
LAYER_GROUP_GEOMETRIES,
@@ -15,18 +14,20 @@ import {
import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { getNumericFeatureProperties } from "../map/value-label";
import type {
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
import { FeatureLocationPanel } from "./feature-location-panel";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
import { NoticeDevTools } from "./notice-dev-tools";
type MapDevPanelProps = {
commands: WorkbenchMapCommands;
@@ -37,6 +38,11 @@ type MapDevPanelProps = {
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps;
onPreviewScadaAlert?: () => void;
onEndScadaAlertPreview?: () => void;
scadaAlertPreviewActive?: boolean;
scadaAlertIntensity?: ScadaAlertIntensity;
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
};
const GROUP_LABELS = Object.fromEntries(
@@ -93,13 +99,7 @@ const LABEL_PRESETS: Record<
};
const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
...FEATURE_LOCATION_ERROR_LABELS,
FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围"
@@ -113,13 +113,21 @@ export function MapDevPanel({
onClose,
investigationFollow = true,
onInvestigationFollowChange,
investigationTools
investigationTools,
onPreviewScadaAlert,
onEndScadaAlertPreview,
scadaAlertPreviewActive = false,
scadaAlertIntensity = "standard",
onScadaAlertIntensityChange
}: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
});
const { sourceId, featureId } = locationDraft;
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const [property, setProperty] = useState("diameter");
const [flowPipeId, setFlowPipeId] = useState("440681");
const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
const [flowVelocity, setFlowVelocity] = useState("-1");
const [precision, setPrecision] = useState(0);
const [unit, setUnit] = useState("mm");
@@ -148,10 +156,6 @@ export function MapDevPanel({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const target: FeatureBatchTarget = { sourceId, featureIds };
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
@@ -171,14 +175,6 @@ export function MapDevPanel({
function useSelectedFeature() {
if (!detailFeature) return;
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId;
@@ -246,7 +242,7 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
@@ -256,7 +252,7 @@ export function MapDevPanel({
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
调查联动验证与地图工具 · 仅当前页面生效
调查、通知与地图工具 · 仅当前页面生效
</p>
</div>
<Button
@@ -272,12 +268,27 @@ export function MapDevPanel({
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
<TabsTrigger value="notices">通知测试</TabsTrigger>
<TabsTrigger value="map">地图调试</TabsTrigger>
</TabsList>
</div>
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
</div> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
</div>
</section> : null}
{onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4">
<div className="space-y-2">
@@ -288,6 +299,9 @@ export function MapDevPanel({
</section>
) : null}
</TabsContent>
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
<NoticeDevTools />
</TabsContent>
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
@@ -297,100 +311,14 @@ export function MapDevPanel({
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
status={controllerState.pending ? "执行中" : undefined}
>
<div className="space-y-3">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<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={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
<FeatureLocationPanel commands={commands} controllerState={controllerState}
detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
onUseSelectedFeature={useSelectedFeature} showError={false}
contentClassName="workbench-shadcn-surface" />
</PanelSection>
<PanelSection
@@ -0,0 +1,28 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import { NoticeDevTools } from "./notice-dev-tools";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("previews every tone, completes loading in place and clears only its own notices", async () => {
render(<NoticeDevTools />);
const user = userEvent.setup();
for (const label of ["信息", "成功", "警告", "错误", "加载"]) {
await user.click(screen.getByRole("button", { name: `测试${label}通知` }));
}
expect(vi.mocked(showMapNotice).mock.calls.map(([options]) => options.tone)).toEqual(["info", "success", "warning", "error", "loading"]);
await user.click(screen.getByRole("button", { name: "模拟加载完成" }));
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "dev-notice:loading", tone: "success" }));
await user.click(screen.getByRole("button", { name: "测试报告通知" }));
const report = vi.mocked(showMapNotice).mock.lastCall![0];
expect(report.actionLabel).toBe("查看报告");
await user.click(screen.getByRole("button", { name: "连续 6 条通知" }));
expect(vi.mocked(showMapNotice).mock.calls.slice(-6).map(([options]) => options.id)).toEqual(
Array.from({ length: 6 }, (_, index) => `dev-notice:batch-${index}`)
);
await user.click(screen.getByRole("button", { name: "清除测试通知" }));
expect(vi.mocked(dismissMapNotice).mock.calls.every(([id]) => typeof id === "string" && id.startsWith("dev-notice:"))).toBe(true);
});
@@ -0,0 +1,87 @@
import { useState } from "react";
import { dismissMapNotice, showMapNotice, type MapNoticeOptions } from "@/features/map/core";
import { Button } from "@/shared/ui/button";
const samples = [
{ key: "info", label: "信息通知", tone: "info", title: "地图视图已更新", message: "已切换到当前项目的管网视图。", lifetime: "3.2 秒后关闭" },
{ key: "success", label: "成功通知", tone: "success", title: "操作已完成", message: "当前管网视图已导出,可以继续分析。", lifetime: "3.2 秒后关闭" },
{ key: "warning", label: "警告通知", tone: "warning", title: "部分监测数据延迟", message: "最新读数尚未到达,请稍后查看。", lifetime: "手动关闭" },
{ key: "error", label: "错误通知", tone: "error", title: "管网数据加载失败", message: "暂时无法连接数据源,请检查连接后重试。", lifetime: "手动关闭" },
{ key: "loading", label: "加载通知", tone: "loading", title: "正在加载管网", message: "正在准备图层和监测数据,请稍候。", lifetime: "等待完成" }
] satisfies (MapNoticeOptions & { key: string; label: string; lifetime: string })[];
const extraKeys = ["report", "task", "long", ...Array.from({ length: 6 }, (_, index) => `batch-${index}`)];
const noticeId = (key: string) => `dev-notice:${key}`;
export function NoticeDevTools() {
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(false);
function showLinked(key: string, target: "报告" | "任务", message?: string) {
showMapNotice({
id: noticeId(key),
tone: target === "报告" ? "success" : "warning",
title: `${target === "报告" ? "调查报告已更新" : "调查任务待处理"} · 测试`,
message: message ?? (target === "报告"
? "城南片区压力异常调查报告已更新,可查看分析结论与处置建议。"
: "新收到一项管网异常调查任务,请查看现场情况与待办事项。"),
actionLabel: `查看${target}`,
onAction: () => setFeedback(`已触发“查看${target}”测试操作,通知仍保留。`)
});
}
function clearSamples() {
[...samples.map((sample) => sample.key), ...extraKeys].forEach((key) => dismissMapNotice(noticeId(key)));
setLoading(false);
setFeedback("已清除测试通知。");
}
return (
<section aria-label="通知测试" className="space-y-5">
<p className="text-xs leading-5 text-muted-foreground">
本地样例,仅当前页面生效。报告和任务入口只记录点击反馈,不创建真实业务数据。
</p>
<div className="space-y-2">
<h3 className="text-xs font-semibold">通知类型</h3>
<div className="grid grid-cols-2 gap-2">
{samples.map(({ key, label, lifetime, ...options }) => (
<Button key={key} type="button" variant="outline" aria-label={`测试${label}`}
className="h-auto min-h-14 flex-col items-start gap-1 px-3 py-2 text-xs"
onClick={() => {
showMapNotice({ ...options, id: noticeId(key), title: `${options.title} · 测试` });
if (key === "loading") setLoading(true);
}}>
<span>{label}</span>
<span className="font-normal text-muted-foreground">{lifetime}</span>
</Button>
))}
</div>
<Button type="button" variant="ghost" size="sm" disabled={!loading}
onClick={() => {
showMapNotice({ id: noticeId("loading"), tone: "success", title: "管网加载完成 · 测试", message: "图层和监测数据已准备就绪。" });
setLoading(false);
}}>模拟加载完成</Button>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">报告与任务入口</h3>
<p className="text-xs leading-5 text-muted-foreground">带操作入口的通知保留至手动关闭,点击查看后继续保留。</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("report", "报告")}>测试报告通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("task", "任务")}>测试任务通知</Button>
</div>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">布局与堆叠</h3>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("long", "报告",
"城南供水片区夜间压力持续下降,多处监测点出现关联异常。调查报告已整理受影响管段、关键监测读数、候选原因和后续核查建议,请结合现场情况查看并安排处置。关联编号:SCADA-PRESSURE-20261107-REPORT-000128。")}>测试长文本通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => {
Array.from({ length: 6 }, (_, index) => showLinked(`batch-${index}`, index % 2 ? "任务" : "报告", `第 ${index + 1} 条测试通知。手动关闭当前通知后,可继续查看其余通知。`));
}}>连续 6 条通知</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={clearSamples}>清除测试通知</Button>
{feedback ? <p role="status" className="text-xs leading-5 text-muted-foreground">{feedback}</p> : null}
</section>
);
}
@@ -1,11 +1,13 @@
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
};
@@ -13,6 +15,7 @@ export function PresentationBusinessResults({
blocks,
records,
reviewing,
chartWindowKey,
onLocateTargets
}: Props) {
if (!blocks.length && !records.length) return null;
@@ -23,6 +26,7 @@ export function PresentationBusinessResults({
key={block.block_id}
block={block}
disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={
onLocateTargets && !reviewing
? (target) =>
@@ -38,40 +42,47 @@ export function PresentationBusinessResults({
))}
{!blocks.length && records.length ? (
<Card className="border-border shadow-none">
<CardHeader className="p-3 pb-2">
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 p-3 pb-2">
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
<FloatingWindow title="调查数据摘要" triggerLabel="放大查看表格 调查数据摘要">{() => <PresentationRecordTable records={records} />}</FloatingWindow>
</CardHeader>
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
<PresentationRecordTable records={records} />
</CardContent>
</Card>
) : null}
</section>
);
}
function PresentationRecordTable({ records }: { records: Array<Record<string, unknown>> }) {
return (
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -58,3 +58,24 @@ it("keeps the final historical result selectable when current has been cleared",
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
expect(onSelect).toHaveBeenCalledWith(history[1].id);
});
it("offers playback, speed, progress and keeps the latest snapshot pinned until returning to current", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
const playback = { playing: true, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
render(<PresentationHistorySwitcher entries={history} currentEntryId={history[1].id} selectedId={history[1].id} onSelect={onSelect} playback={playback} />);
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/2");
await user.click(screen.getByRole("button", { name: "暂停历史播放" }));
expect(playback.onToggle).toHaveBeenCalledTimes(1);
const progress = screen.getByRole("slider", { name: "历史播放进度" });
progress.focus();
await user.keyboard("{ArrowLeft}");
// jsdom does not implement the browser's native range keyboard default.
await user.click(screen.getByRole("combobox", { name: "历史播放速度" }));
await user.click(screen.getByRole("option", { name: /^2×$/ }));
expect(playback.onSpeedChange).toHaveBeenCalledWith(2);
await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null);
});
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -9,88 +9,116 @@ export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
onSelect
onSelect,
playback
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter(
(entry) => entry.status !== "running" && entry.id !== currentEntryId
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
);
const index =
selectedId === null
? available.length
: available.findIndex((entry) => entry.id === selectedId);
const playable = entries.filter((entry) => entry.status === "completed");
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
return (
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
<div className="flex min-w-0 items-center gap-1">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<Radio size={12} aria-hidden />
回到当前
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId && entry.id !== selectedId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
>
<Radio size={12} aria-hidden />
回到当前
</Button>
) : null}
</div>
{playback ? (
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0"
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
disabled={!playable.length} onClick={playback.onToggle}>
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
</Button>
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress}
disabled={playable.length < 2}
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
</span>
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]">
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
@@ -14,7 +14,7 @@ export function ProjectStartupScreen({ state, error, onRetry }: {
<StartupScreen
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
description={error ? "项目加载已中断,具体原因如下。" : "正在准备你的项目工作台,完成后自动进入。"}
progressLabel="项目加载进度"
steps={[
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
@@ -0,0 +1,40 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { ScadaAlertAtmosphere } from "./scada-alert-atmosphere";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
const preview: ScadaAlertAtmosphereState = { id: "preview-1", projectId: "project-1", preview: true, alert: null };
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("shows a local preview with a dismiss button and no investigation action", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管告警效果预览");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
const close = screen.getByRole("button", { name: "关闭告警灯效" });
expect(close).toHaveTextContent("关闭灯效");
fireEvent.click(close);
expect(onDismiss).toHaveBeenCalledWith("preview-1");
expect(onOpen).not.toHaveBeenCalled();
});
it("opens the actual linked investigation while retaining the visual warning", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
const alert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常", description: "已启动调查", time: "08:00" };
render(<ScadaAlertAtmosphere atmosphere={{ ...preview, id: alert.id, preview: false, alert }} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管风险调查中");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onDismiss).not.toHaveBeenCalled();
expect(onOpen).toHaveBeenCalledWith(alert);
});
it("keeps the warning for a long-running event until its state is removed", () => {
vi.useFakeTimers();
const onDismiss = vi.fn(), onOpen = vi.fn();
const { rerender } = render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
act(() => vi.advanceTimersByTime(24 * 60 * 60_000));
expect(onDismiss).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toBeVisible();
rerender(<ScadaAlertAtmosphere atmosphere={null} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
@@ -0,0 +1,41 @@
import { createPortal } from "react-dom";
import { ArrowUpRight, TriangleAlert, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
import type { WorkbenchAlert } from "../types";
export type ScadaAlertIntensity = "soft" | "standard" | "strong";
/** Event-lifetime viewport warning with an independently dismissible visual layer. */
export function ScadaAlertAtmosphere({ atmosphere, onDismiss, onOpen, intensity = "standard" }: {
atmosphere: ScadaAlertAtmosphereState | null;
onDismiss: (id: string) => void;
onOpen: (alert: WorkbenchAlert) => void;
intensity?: ScadaAlertIntensity;
}) {
if (!atmosphere) return null;
const alert = atmosphere.alert;
return createPortal(
<div className="scada-alert-atmosphere status-tone-danger"
data-testid="scada-alert-atmosphere" data-alert-id={atmosphere.id} data-preview={atmosphere.preview} data-intensity={intensity}>
<div className="scada-alert-atmosphere-frame" aria-hidden="true" />
<div className="scada-alert-atmosphere-light" aria-hidden="true" />
<div role="status" aria-live="polite" className="scada-alert-atmosphere-notice glass-transient items-center gap-3 rounded-xl border p-3 text-foreground">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[var(--status-soft)] text-[var(--status-foreground)]">
<TriangleAlert size={19} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-[13px] leading-5 font-semibold">{atmosphere.preview ? "爆管告警效果预览"
: (atmosphere.count ?? 1) > 1 ? `${atmosphere.count} 起爆管风险事件处理中` : "爆管风险调查中"}</p>
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">{atmosphere.preview ? "持续预览 · 可在 Dev 面板模拟结束" : "持续至事件处理结束"}</p>
</div>
{alert?.conditionId ? <Button type="button" size="sm" variant="ghost" className="scada-alert-atmosphere-action h-9 shrink-0 gap-1 px-2 text-xs"
onClick={() => onOpen(alert)}>
查看调查<ArrowUpRight size={14} aria-hidden="true" />
</Button> : null}
<Button type="button" size="sm" variant="outline" className="scada-alert-atmosphere-close h-9 shrink-0 gap-1.5 px-2.5 text-xs" aria-label="关闭告警灯效" title="关闭当前视觉警告"
onClick={() => onDismiss(atmosphere.id)}><X size={14} aria-hidden="true" />关闭灯效</Button>
</div>
</div>, document.body
);
}
@@ -48,6 +48,8 @@ import {
import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel";
import { FeatureLocationPanel, type FeatureLocationPanelProps } from "./feature-location-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
@@ -98,6 +100,7 @@ const DRAW_TOOL_OPTIONS: Array<{
export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding;
featureLocation: FeatureLocationPanelProps;
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
@@ -140,6 +143,7 @@ export type ToolbarPanelProps = {
export function ToolbarPanel({
geocoding,
featureLocation,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -203,6 +207,7 @@ export function ToolbarPanel({
);
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const [locationMode, setLocationMode] = useState("address");
const panelVisible = Boolean(activeToolId);
useEffect(() => {
@@ -232,12 +237,28 @@ export function ToolbarPanel({
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
key="geocoding" title="定位" description={locationMode === "features" ? "按图层和要素 ID 定位" : geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
>
<GeocodingPanel geocoding={geocoding} />
<Tabs value={locationMode} onValueChange={(value) => {
if (value === "features" && geocoding.picking) geocoding.togglePicking();
setLocationMode(value);
}} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList aria-label="定位方式" className="surface-control grid shrink-0 grid-cols-2">
<TabsTrigger value="address">地址地名</TabsTrigger>
<TabsTrigger value="features">管网要素</TabsTrigger>
</TabsList>
<TabsContent value="address" forceMount hidden={locationMode !== "address"}
className="flex min-h-0 flex-1 flex-col overflow-hidden">
<GeocodingPanel geocoding={geocoding} />
</TabsContent>
<TabsContent value="features" forceMount hidden={locationMode !== "features"}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-0.5">
<FeatureLocationPanel {...featureLocation} />
</TabsContent>
</Tabs>
</MapControlPanel>
);
}
@@ -123,6 +123,7 @@ export function AlertMenu({
compact = false,
conditionFeedVisible,
onSelectAlert,
onRemoveAlert,
onToggleConditionFeed,
onAnalyzeAlertsWithAgent
}: {
@@ -133,6 +134,7 @@ export function AlertMenu({
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
@@ -200,30 +202,43 @@ export function AlertMenu({
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
return (
<DropdownMenuItem
<div
key={alert.id}
className={cn(
toneClassName,
"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
)}
onSelect={() => onSelectAlert(alert)}
className={cn("surface-well my-1 px-3 pb-3 pt-1", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2 text-xs text-[var(--ink-muted)]">
<StatusDot tone={tone} 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-[var(--ink-muted)] tabular-nums">{alert.time}</span>
<span className="font-mono tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</span>
</span>
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-[var(--surface-reading)] text-[var(--ink-muted)] transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
<div className="flex shrink-0 items-center gap-0.5">
<DropdownMenuItem
aria-label={`打开通知:${alert.title}`}
className={cn("h-10 justify-center gap-1 px-2 text-xs font-medium text-[var(--action-blue)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
onSelect={() => onSelectAlert(alert)}
>
打开
<ChevronRight size={14} aria-hidden="true" />
</DropdownMenuItem>
{onRemoveAlert ? (
<DropdownMenuItem
aria-label={`移除通知:${alert.title}`}
title="移除通知"
className={cn("h-10 w-10 justify-center p-0 text-[var(--ink-muted)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
onSelect={(event) => {
event.preventDefault();
onRemoveAlert(alert);
}}
>
<X size={14} aria-hidden="true" />
</DropdownMenuItem>
) : null}
</div>
</div>
<p className="break-words text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
<p className="mt-1 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
</div>
);
}) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
devPanelEnabled: boolean;
devPanelOpen: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
@@ -81,6 +82,7 @@ export function WorkbenchTopBar({
devPanelEnabled,
devPanelOpen,
onSelectAlert,
onRemoveAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
@@ -145,6 +147,7 @@ export function WorkbenchTopBar({
sourceLabel={getAlertSourceLabel(alertDataSource)}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onRemoveAlert={onRemoveAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
@@ -242,6 +245,7 @@ export function WorkbenchTopBar({
sourceLabel={getAlertSourceLabel(alertDataSource)}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onRemoveAlert={onRemoveAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>