feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -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 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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user