feat: extend map targeting and overlay controls
Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries. Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
@@ -204,9 +204,7 @@ export function MapActionRow({
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")} />
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ChevronRight, Copy, Ruler, Trash2 } from "lucide-react";
|
||||
import { Copy, Ruler, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
@@ -276,15 +276,11 @@ function MeasureActionButton({
|
||||
) : (
|
||||
<span>{action.label}</span>
|
||||
)}
|
||||
{primary ? (
|
||||
active ? (
|
||||
{primary && active ? (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
|
||||
进行中
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--ink-muted)]" />
|
||||
)
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,28 @@ import { MapDevPanel } from "./map-dev-panel";
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("MapDevPanel", () => {
|
||||
it("deduplicates pasted IDs for every location action and prevents oversized batches", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
|
||||
detailFeature={null} onClose={vi.fn()} />);
|
||||
const input = screen.getByLabelText("要素 ID(可多个)");
|
||||
await user.clear(input);
|
||||
await user.paste("P-1,P-2\nP-1 P-3;P-4");
|
||||
const target = { sourceId: "pipes", featureIds: ["P-1", "P-2", "P-3", "P-4"] };
|
||||
await user.click(screen.getByRole("button", { name: "仅高亮" }));
|
||||
expect(commands.highlight).toHaveBeenCalledWith(target);
|
||||
await user.click(screen.getByRole("button", { name: "缩放到要素" }));
|
||||
expect(commands.zoomToFeature).toHaveBeenCalledWith(target);
|
||||
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
|
||||
expect(commands.locateAndHighlight).toHaveBeenCalledWith(target);
|
||||
await user.clear(input);
|
||||
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
|
||||
await user.paste(Array.from({ length: 101 }, (_, i) => `P-${i}`).join(","));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("单次最多 100 个");
|
||||
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("defaults to following investigations and allows disabling it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFollowChange = vi.fn();
|
||||
@@ -109,6 +131,8 @@ function createControllerState(
|
||||
): WorkbenchMapControllerState {
|
||||
return {
|
||||
target: null,
|
||||
targets: [],
|
||||
targetResult: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
|
||||
@@ -5,15 +5,17 @@ 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 {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "../map/layer-group-style";
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureTarget,
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
@@ -90,6 +92,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 设备",
|
||||
@@ -140,7 +143,10 @@ export function MapDevPanel({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
|
||||
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;
|
||||
@@ -286,13 +292,13 @@ export function MapDevPanel({
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="读取目标要素后,执行地图高亮或相机定位。"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
@@ -305,13 +311,21 @@ export function MapDevPanel({
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="Feature ID">
|
||||
<Input
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
className={inputClassName}
|
||||
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"
|
||||
@@ -325,7 +339,7 @@ export function MapDevPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
@@ -334,17 +348,17 @@ export function MapDevPanel({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到 Feature
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
@@ -359,9 +373,15 @@ export function MapDevPanel({
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.target ? (
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
|
||||
已高亮 {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}
|
||||
</PanelSection>
|
||||
@@ -423,8 +443,8 @@ export function MapDevPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -803,11 +823,11 @@ function RangeField({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
function StatusText({ children, tone = "success" }: { children: React.ReactNode; tone?: "success" | "warning" }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
|
||||
className={`status-tone-${tone} break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Copy,
|
||||
Database,
|
||||
Dot,
|
||||
Download,
|
||||
Eraser,
|
||||
Keyboard,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
@@ -134,7 +134,7 @@ export type ToolbarPanelProps = {
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onClearMapOverlays: () => void;
|
||||
panelWidthClassName?: string;
|
||||
};
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ToolbarPanel({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onClearMapOverlays,
|
||||
panelWidthClassName
|
||||
}: ToolbarPanelProps) {
|
||||
const annotationItems = useMemo<MapAnnotationItem[]>(
|
||||
@@ -403,7 +403,7 @@ export function ToolbarPanel({
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onClearMapOverlays={onClearMapOverlays}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
@@ -476,22 +476,25 @@ function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
|
||||
}
|
||||
|
||||
type MorePanelProps = {
|
||||
onClearMapOverlays: () => void;
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
function MorePanel({
|
||||
onClearMapOverlays,
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onShowShortcuts
|
||||
}: MorePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="清理地图">
|
||||
<MapActionRow icon={Eraser} label="清空所有高亮和覆盖层" description="分析、测量、标注与定位标记" onClick={onClearMapOverlays} />
|
||||
</MapPanelSection>
|
||||
<MapPanelSection title="导出视图">
|
||||
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
|
||||
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
|
||||
@@ -504,7 +507,6 @@ function MorePanel({
|
||||
|
||||
<MapPanelSection title="系统">
|
||||
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
|
||||
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
Download,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
@@ -282,7 +281,6 @@ export function UserMenu({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -292,7 +290,6 @@ export function UserMenu({
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
@@ -335,7 +332,6 @@ export function UserMenu({
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -59,7 +59,6 @@ export type WorkbenchTopBarProps = {
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -97,7 +96,6 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
@@ -256,7 +254,6 @@ export function WorkbenchTopBar({
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,24 @@ function setup(overrides: Partial<ConstructorParameters<typeof InvestigationPres
|
||||
}
|
||||
|
||||
describe("live investigation presentation following", () => {
|
||||
it("discards queued map commands while retaining chart commands and later new map events", async () => {
|
||||
const painting = deferred<void>();
|
||||
const execute = vi.fn((command: InvestigationPresentationCommand) =>
|
||||
command.action === "open_chart" ? painting.promise : Promise.resolve());
|
||||
const test = setup({ execute });
|
||||
test.follower.start();
|
||||
test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
test.channel.publish({ type: "events", events: [event(11, openChart), event(12), event(13, closeChart)] });
|
||||
test.follower.discardPendingMapCommands();
|
||||
painting.resolve();
|
||||
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart"]));
|
||||
test.channel.publish({ type: "events", events: [event(14)] });
|
||||
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart", "highlight_features"]));
|
||||
} finally { painting.resolve(); test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("listens before fetching the baseline and preserves events arriving during cursor lookup", async () => {
|
||||
const baseline = deferred<InvestigationPresentationCursor>();
|
||||
const order: string[] = [];
|
||||
|
||||
@@ -43,6 +43,12 @@ export class InvestigationPresentationFollower {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
discardPendingMapCommands() {
|
||||
this.pending = this.pending.filter(({ event }) =>
|
||||
event.command.action === "open_chart" || event.command.action === "close_chart"
|
||||
);
|
||||
}
|
||||
|
||||
updateIdentity(identity: InvestigationIdentity) {
|
||||
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
|
||||
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
|
||||
@@ -138,6 +144,7 @@ export class InvestigationPresentationFollower {
|
||||
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
|
||||
await this.resolveIdentity(next.event);
|
||||
if (epoch !== this.epoch) break;
|
||||
if (this.pending[0] !== next) continue;
|
||||
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
|
||||
}
|
||||
this.pending.shift();
|
||||
|
||||
@@ -55,15 +55,25 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
|
||||
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
|
||||
const manualRequestRef = useRef<AbortController | null>(null);
|
||||
const mapCommandsRef = useRef(new AbortController());
|
||||
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
|
||||
const taskId = options.task?.task_id ?? null;
|
||||
const subscribe = options.sync.subscribePresentationEvents;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
scene.clear(); setCharts([]); setOverlay(null); setHighlightLayers([]);
|
||||
valuesRef.current.onClearLayers();
|
||||
const clearMapOverlays = useCallback((discardPending = true) => {
|
||||
mapCommandsRef.current.abort();
|
||||
mapCommandsRef.current = new AbortController();
|
||||
manualRequestRef.current?.abort();
|
||||
if (discardPending) followerRef.current?.discardPendingMapCommands();
|
||||
scene.clear(); setOverlay(null); setHighlightLayers([]);
|
||||
}, [scene]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
// New task generations retain their first queued map command.
|
||||
clearMapOverlays(false); setCharts([]);
|
||||
valuesRef.current.onClearLayers();
|
||||
}, [clearMapOverlays]);
|
||||
|
||||
useEffect(() => {
|
||||
manualRequestRef.current?.abort();
|
||||
cameraPausedRef.current = false;
|
||||
@@ -77,7 +87,12 @@ export function useInvestigationPresentation(options: Options) {
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
|
||||
execute: async (command, signal) => {
|
||||
const sourceSignal = signal;
|
||||
if (command.action !== "open_chart" && command.action !== "close_chart") {
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
if (command.action === "highlight_features") {
|
||||
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
|
||||
signal.throwIfAborted();
|
||||
@@ -98,6 +113,10 @@ export function useInvestigationPresentation(options: Options) {
|
||||
scene.clearOverlay(command.overlay_id);
|
||||
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (signal.aborted && !sourceSignal.aborted) return;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
});
|
||||
followerRef.current = follower;
|
||||
@@ -138,7 +157,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
}, [pauseCamera, scene]);
|
||||
|
||||
return {
|
||||
charts, overlay, cameraPaused, status, error, highlightLayers,
|
||||
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
|
||||
pauseCamera,
|
||||
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
|
||||
locateTargets,
|
||||
|
||||
@@ -3,6 +3,7 @@ export { ProjectWorkbench } from "./project-workbench";
|
||||
export { WorkbenchMapController } from "./map/workbench-map-controller";
|
||||
export type {
|
||||
FeatureTarget,
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState,
|
||||
WorkbenchMapErrorCode
|
||||
|
||||
@@ -1108,25 +1108,16 @@ function MapWorkbenchContent({
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportConfig() {
|
||||
const config = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
activeBaseLayerId,
|
||||
layerVisibility,
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "supply-network-map-config.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showMapNotice({ tone: "success", message: "当前地图配置已导出。" });
|
||||
function handleClearMapOverlays() {
|
||||
mapController.clearOverlays();
|
||||
presentation.clearMapOverlays();
|
||||
handleClearSelection();
|
||||
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||
measurement.clear();
|
||||
drawing.clear();
|
||||
geocoding.clear();
|
||||
handleToggleLayer("simulation", false);
|
||||
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
|
||||
}
|
||||
|
||||
const toolbarPanelProps: ToolbarPanelProps = {
|
||||
@@ -1167,7 +1158,7 @@ function MapWorkbenchContent({
|
||||
onRefreshTiles: handleRefreshTiles,
|
||||
onShowDataStatus: handleShowDataStatus,
|
||||
onShowShortcuts: handleShowShortcuts,
|
||||
onExportConfig: handleExportConfig
|
||||
onClearMapOverlays: handleClearMapOverlays
|
||||
};
|
||||
const selectedOperationalEvent =
|
||||
operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
|
||||
@@ -1377,7 +1368,6 @@ function MapWorkbenchContent({
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onExportConfig={handleExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,6 +9,112 @@ import {
|
||||
import { WorkbenchMapController } from "./workbench-map-controller";
|
||||
|
||||
describe("WorkbenchMapController", () => {
|
||||
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
const setData = vi.fn();
|
||||
map.getSource.mockReturnValue({ setData });
|
||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
||||
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
|
||||
});
|
||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
||||
const showingFlow = controller.setFlowVisible(true);
|
||||
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
|
||||
controller.clearOverlays();
|
||||
await showingFlow;
|
||||
resolveFeatures(pointCollection);
|
||||
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
|
||||
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
target: null, targets: [], targetResult: null, scadaAnalysis: null,
|
||||
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
||||
});
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(FLOW_LINE_LAYER_ID, "visibility", "none");
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries a batch once, fits all matches, and replaces and clears every highlight", async () => {
|
||||
const map = createMap();
|
||||
const collection: FeatureCollection = { type: "FeatureCollection", features: [
|
||||
...pointCollection.features,
|
||||
{ type: "Feature", properties: { id: "J-2" }, geometry: { type: "Point", coordinates: [121, 29] } }
|
||||
] };
|
||||
const fetchFeatures = vi.fn(async () => collection);
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||
getPadding: () => ({ top: 48, right: 48, bottom: 48, left: 48 }), fetchFeatures
|
||||
});
|
||||
await expect(controller.locateFeatures("junctions", ["J-1", "J-2", "J-1", "missing"])).resolves.toEqual({
|
||||
located_ids: ["J-1", "J-2"], missing_ids: ["missing"], fitted: true
|
||||
});
|
||||
expect(fetchFeatures).toHaveBeenCalledExactlyOnceWith("junctions", ["J-1", "J-2", "missing"], undefined);
|
||||
expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [121, 29]], expect.objectContaining({ maxZoom: 18 }));
|
||||
for (const id of ["J-1", "J-2"]) {
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, { highlighted: true });
|
||||
}
|
||||
expect(controller.getSnapshot().targets).toHaveLength(2);
|
||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
for (const id of ["J-1", "J-2"]) {
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, "highlighted");
|
||||
}
|
||||
await controller.highlight({ sourceId: "junctions", featureIds: ["J-1", "J-2"] });
|
||||
map.removeFeatureState.mockClear();
|
||||
controller.clearHighlight();
|
||||
expect(map.removeFeatureState).toHaveBeenCalledTimes(2);
|
||||
expect(controller.getSnapshot()).toMatchObject({ targets: [], target: null, targetResult: null });
|
||||
});
|
||||
|
||||
it("keeps highlight-only and zoom-only batch behavior separate", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, lineCollection);
|
||||
const target = { sourceId: "pipes" as const, featureIds: ["P-1", "missing"] };
|
||||
await controller.highlight(target);
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().targetResult).toMatchObject({ featureIds: ["P-1"], missingIds: ["missing"] });
|
||||
map.setFeatureState.mockClear();
|
||||
await controller.zoomToFeature(target);
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().targets).toEqual([{ sourceId: "pipes", featureId: "P-1" }]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight batch without applying late highlights or moving the camera", async () => {
|
||||
const map = createMap();
|
||||
const abort = new AbortController();
|
||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
||||
const fetchFeatures = vi.fn(() => new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; }));
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures
|
||||
});
|
||||
const locating = controller.locateFeatures("junctions", ["J-1", "J-2"], abort.signal);
|
||||
abort.abort();
|
||||
resolveFeatures(pointCollection);
|
||||
await expect(locating).rejects.toThrow();
|
||||
expect(fetchFeatures).toHaveBeenCalledWith("junctions", ["J-1", "J-2"], abort.signal);
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot()).toMatchObject({ pending: false, targets: [] });
|
||||
});
|
||||
|
||||
it("rejects empty and oversized batches without querying or moving", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
for (const ids of [[], [" "], Array.from({ length: 101 }, (_, i) => `J-${i}`)]) {
|
||||
await expect(controller.locateFeatures("junctions", ids)).rejects.toThrow("INVALID_FEATURE_IDS");
|
||||
}
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
MAX_PIPE_FEATURES,
|
||||
MAX_MAP_FEATURE_IDS,
|
||||
MAX_MAP_FEATURE_ID_LENGTH,
|
||||
createMapFeatureWfsUrl,
|
||||
normalizeMapFeatureCollection,
|
||||
parseMapFeatureQuery
|
||||
@@ -44,9 +45,14 @@ import {
|
||||
} from "./scada-analysis";
|
||||
|
||||
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
||||
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
||||
type FeatureCommandTarget = FeatureTarget | FeatureBatchTarget;
|
||||
type FeatureCommandMode = "highlight" | "zoom" | "locate";
|
||||
type FeatureCommandResult = FeatureBatchTarget & { missingIds: string[]; mode: FeatureCommandMode };
|
||||
export type WorkbenchMapErrorCode =
|
||||
| "MAP_NOT_READY"
|
||||
| "FEATURE_NOT_FOUND"
|
||||
| "INVALID_FEATURE_IDS"
|
||||
| "WFS_UNAVAILABLE"
|
||||
| "SCADA_API_UNAVAILABLE"
|
||||
| "SCADA_FEATURES_NOT_FOUND"
|
||||
@@ -57,6 +63,8 @@ export type WorkbenchMapErrorCode =
|
||||
|
||||
export type WorkbenchMapControllerState = {
|
||||
target: FeatureTarget | null;
|
||||
targets: FeatureTarget[];
|
||||
targetResult: FeatureCommandResult | null;
|
||||
valueLabels: Partial<Record<ValueLabelSourceId, ValueLabelStyle>>;
|
||||
pending: boolean;
|
||||
errorCode: WorkbenchMapErrorCode | null;
|
||||
@@ -68,9 +76,9 @@ export type WorkbenchMapControllerState = {
|
||||
|
||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||
export type WorkbenchMapCommands = {
|
||||
zoomToFeature: (target: FeatureTarget) => Promise<void>;
|
||||
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
|
||||
highlight: (target: FeatureTarget) => Promise<void>;
|
||||
zoomToFeature: (target: FeatureCommandTarget) => Promise<void>;
|
||||
locateAndHighlight: (target: FeatureCommandTarget) => Promise<void>;
|
||||
highlight: (target: FeatureCommandTarget) => Promise<void>;
|
||||
clearHighlight: () => void;
|
||||
setFlowVisible: (visible: boolean) => Promise<void>;
|
||||
replacePipeFlowCalculations: (records: readonly PipeFlowCalculation[]) => Promise<PipeFlowSummary | null>;
|
||||
@@ -98,6 +106,8 @@ export type WorkbenchMapControllerOptions = {
|
||||
export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
private state: WorkbenchMapControllerState = {
|
||||
target: null,
|
||||
targets: [],
|
||||
targetResult: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
@@ -107,15 +117,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
scadaAnalysis: null
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private highlightedTarget: FeatureTarget | null = null;
|
||||
private cachedFeatures = new Map<string, Feature>();
|
||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||
private flowUpdateRevision = 0;
|
||||
private flowVisibilityRevision = 0;
|
||||
private scadaAnalysisRevision = 0;
|
||||
private targetRequestRevision = 0;
|
||||
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
||||
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
|
||||
|
||||
constructor(private readonly options: WorkbenchMapControllerOptions) {}
|
||||
|
||||
@@ -126,47 +135,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
getSnapshot = () => this.state;
|
||||
|
||||
async zoomToFeature(target: FeatureTarget) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
if (!this.requireTargetMap(target)) return;
|
||||
const feature = await this.resolveTarget(target, requestRevision);
|
||||
if (requestRevision !== this.targetRequestRevision) return;
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!feature || !map) return;
|
||||
this.moveCameraToFeature(map, feature);
|
||||
async zoomToFeature(target: FeatureCommandTarget) {
|
||||
await this.executeFeatureCommand(target, "zoom");
|
||||
}
|
||||
|
||||
async locateAndHighlight(target: FeatureTarget) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
if (!this.requireTargetMap(target)) return;
|
||||
const feature = await this.resolveTarget(target, requestRevision);
|
||||
if (requestRevision !== this.targetRequestRevision) return;
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!feature || !map) return;
|
||||
if (!this.moveCameraToFeature(map, feature)) return;
|
||||
this.setHighlight(target);
|
||||
async locateAndHighlight(target: FeatureCommandTarget) {
|
||||
await this.executeFeatureCommand(target, "locate");
|
||||
}
|
||||
|
||||
async locateFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal?: AbortSignal) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
const target = { sourceId, featureId: featureIds[0] ?? "" };
|
||||
if (!this.requireTargetMap(target)) throw new Error(this.state.errorCode ?? "MAP_NOT_READY");
|
||||
const found: Array<{ id: string; feature: Feature }> = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of [...new Set(featureIds)]) {
|
||||
signal?.throwIfAborted();
|
||||
const feature = await this.resolveTarget({ sourceId, featureId: id }, requestRevision);
|
||||
const requestRevision = this.targetRequestRevision + 1;
|
||||
const result = await this.executeFeatureCommand({ sourceId, featureIds }, "locate", signal, 0);
|
||||
if (requestRevision !== this.targetRequestRevision) throw new Error("ACTION_SUPERSEDED");
|
||||
if (this.state.errorCode === "WFS_UNAVAILABLE" || this.state.errorCode === "SCADA_API_UNAVAILABLE") throw new Error(this.state.errorCode);
|
||||
if (feature) found.push({ id, feature }); else missing.push(id);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!map || found.length === 0) throw new Error(this.state.errorCode ?? "FEATURE_NOT_FOUND");
|
||||
const combined: Feature = { type: "Feature", properties: {}, geometry: { type: "MultiPoint", coordinates: found.flatMap(({ feature }) => collectCoordinates(feature.geometry)) } };
|
||||
if (!this.moveCameraToFeature(map, combined, 0)) throw new Error("FEATURE_NOT_FOUND");
|
||||
this.setHighlight({ sourceId, featureId: found[0].id });
|
||||
return { located_ids: found.map(({ id }) => id), missing_ids: missing, fitted: true as const };
|
||||
if (!result) throw new Error(this.state.errorCode ?? "ACTION_SUPERSEDED");
|
||||
return { located_ids: result.featureIds, missing_ids: result.missingIds, fitted: true as const };
|
||||
}
|
||||
|
||||
/** Resolve a task-owned overlay without changing the user's selection or controller highlight. */
|
||||
@@ -209,36 +191,23 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return true;
|
||||
}
|
||||
|
||||
async highlight(target: FeatureTarget) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
if (!this.requireTargetMap(target)) return;
|
||||
const feature = await this.resolveTarget(target, requestRevision);
|
||||
if (requestRevision !== this.targetRequestRevision) return;
|
||||
if (feature) this.setHighlight(target);
|
||||
async highlight(target: FeatureCommandTarget) {
|
||||
await this.executeFeatureCommand(target, "highlight");
|
||||
}
|
||||
|
||||
clearHighlight = () => {
|
||||
this.beginTargetCommand();
|
||||
const map = this.options.getMap();
|
||||
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
for (const target of this.state.targets) {
|
||||
if (map?.getSource(target.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
|
||||
}
|
||||
this.highlightedTarget = null;
|
||||
this.patchState({ target: null, errorCode: null });
|
||||
}
|
||||
this.patchState({ target: null, targets: [], targetResult: null, errorCode: null });
|
||||
};
|
||||
|
||||
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
|
||||
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
|
||||
if (this.availableSourceIds) {
|
||||
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
|
||||
this.sourceAvailabilityGenerations.set(
|
||||
sourceId,
|
||||
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.availableSourceIds = availableSourceIds;
|
||||
|
||||
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||
@@ -249,9 +218,9 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
});
|
||||
|
||||
const targetUnavailable = Boolean(
|
||||
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
|
||||
);
|
||||
const unavailableTargets = this.state.targets.filter((target) => !availableSourceIds.has(target.sourceId));
|
||||
const targetUnavailable = unavailableTargets.length > 0;
|
||||
const resultUnavailable = Boolean(this.state.targetResult && !availableSourceIds.has(this.state.targetResult.sourceId));
|
||||
const pendingTargetUnavailable = Boolean(
|
||||
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
|
||||
);
|
||||
@@ -259,12 +228,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.targetRequestRevision += 1;
|
||||
this.pendingTargetSourceId = null;
|
||||
}
|
||||
if (targetUnavailable && this.highlightedTarget) {
|
||||
const map = this.options.getMap();
|
||||
if (map?.getSource(this.highlightedTarget.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
for (const target of unavailableTargets) {
|
||||
if (map?.getSource(target.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
|
||||
}
|
||||
this.highlightedTarget = null;
|
||||
}
|
||||
|
||||
const valueLabelEntries = Object.entries(this.state.valueLabels);
|
||||
@@ -278,6 +246,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
|
||||
if (
|
||||
!targetUnavailable &&
|
||||
!resultUnavailable &&
|
||||
!pendingTargetUnavailable &&
|
||||
!valueLabelsChanged &&
|
||||
!styledGroupsChanged
|
||||
@@ -285,6 +254,8 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
this.patchState({
|
||||
target: targetUnavailable ? null : this.state.target,
|
||||
targets: targetUnavailable ? [] : this.state.targets,
|
||||
targetResult: resultUnavailable ? null : this.state.targetResult,
|
||||
pending: pendingTargetUnavailable ? false : this.state.pending,
|
||||
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
|
||||
valueLabels: valueLabelsChanged
|
||||
@@ -295,6 +266,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
|
||||
async setFlowVisible(visible: boolean) {
|
||||
const revision = ++this.flowVisibilityRevision;
|
||||
const map = this.requireMap();
|
||||
if (!map) return;
|
||||
if (!visible) {
|
||||
@@ -309,8 +281,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.flowState,
|
||||
this.options.reducedMotion?.()
|
||||
);
|
||||
if (revision !== this.flowVisibilityRevision) return;
|
||||
this.patchState({ pending: false, flowVisible: true });
|
||||
} catch {
|
||||
if (revision !== this.flowVisibilityRevision) return;
|
||||
hideFlowOverlay(map, this.flowState);
|
||||
this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" });
|
||||
}
|
||||
@@ -533,10 +507,18 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
resetAll = () => {
|
||||
clearOverlays = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
this.flowVisibilityRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.patchState({ flowVisible: false });
|
||||
};
|
||||
|
||||
resetAll = () => {
|
||||
this.clearOverlays();
|
||||
const map = this.options.getMap();
|
||||
if (map) {
|
||||
hideFlowOverlay(map, this.flowState);
|
||||
@@ -574,42 +556,86 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return requestRevision;
|
||||
}
|
||||
|
||||
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
|
||||
const map = this.requireMap();
|
||||
if (!map) return null;
|
||||
const key = `${target.sourceId}:${target.featureId}`;
|
||||
private async executeFeatureCommand(
|
||||
input: FeatureCommandTarget,
|
||||
mode: FeatureCommandMode,
|
||||
signal?: AbortSignal,
|
||||
durationOverride?: number
|
||||
): Promise<FeatureCommandResult | null> {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
const featureIds = [...new Set(("featureIds" in input ? input.featureIds : [input.featureId]).map((id) => id.trim()))];
|
||||
this.patchState({ targetResult: null, errorCode: null });
|
||||
if (!featureIds.length || featureIds.length > MAX_MAP_FEATURE_IDS ||
|
||||
featureIds.some((id) => !id || id.length > MAX_MAP_FEATURE_ID_LENGTH)) {
|
||||
this.setError("INVALID_FEATURE_IDS");
|
||||
return null;
|
||||
}
|
||||
const { sourceId } = input;
|
||||
const target = { sourceId, featureId: featureIds[0] };
|
||||
if (!this.requireTargetMap(target)) return null;
|
||||
const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
|
||||
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
|
||||
sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
|
||||
);
|
||||
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
|
||||
if (cached) return cached;
|
||||
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
|
||||
this.pendingTargetSourceId = target.sourceId;
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
try {
|
||||
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
|
||||
if (
|
||||
requestRevision !== this.targetRequestRevision ||
|
||||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
|
||||
) return null;
|
||||
this.pendingTargetSourceId = null;
|
||||
const feature = collection.features.find((item) => String(item.properties?.id ?? item.properties?.device_id ?? item.id) === target.featureId);
|
||||
if (!feature) {
|
||||
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
||||
return null;
|
||||
}
|
||||
if (!dataDependent) this.cachedFeatures.set(key, feature);
|
||||
this.patchState({ pending: false });
|
||||
return feature;
|
||||
} catch {
|
||||
if (requestRevision !== this.targetRequestRevision) return null;
|
||||
this.pendingTargetSourceId = null;
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
|
||||
const found = new Map<string, Feature>();
|
||||
const uncachedIds = featureIds.filter((id) => {
|
||||
const cached = dataDependent ? undefined : this.cachedFeatures.get(`${sourceId}:${id}`);
|
||||
if (cached) found.set(id, cached);
|
||||
return !cached;
|
||||
});
|
||||
this.pendingTargetSourceId = sourceId;
|
||||
this.patchState({ pending: true });
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (uncachedIds.length) {
|
||||
const collection = await this.fetchFeatures(sourceId, uncachedIds, signal);
|
||||
if (requestRevision !== this.targetRequestRevision) return null;
|
||||
signal?.throwIfAborted();
|
||||
const requestedIds = new Set(uncachedIds);
|
||||
for (const feature of collection.features) {
|
||||
const id = String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id);
|
||||
if (!requestedIds.has(id) || !collectCoordinates(feature.geometry).length) continue;
|
||||
found.set(id, feature);
|
||||
if (!dataDependent) this.cachedFeatures.set(`${sourceId}:${id}`, feature);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestRevision !== this.targetRequestRevision) return null;
|
||||
if (signal?.aborted) throw error;
|
||||
this.setError(sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE");
|
||||
return null;
|
||||
} finally {
|
||||
if (requestRevision === this.targetRequestRevision) {
|
||||
this.pendingTargetSourceId = null;
|
||||
this.patchState({ pending: false });
|
||||
}
|
||||
}
|
||||
if (requestRevision !== this.targetRequestRevision) return null;
|
||||
signal?.throwIfAborted();
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!map) return null;
|
||||
const result: FeatureCommandResult = {
|
||||
sourceId, mode,
|
||||
featureIds: featureIds.filter((id) => found.has(id)),
|
||||
missingIds: featureIds.filter((id) => !found.has(id))
|
||||
};
|
||||
this.patchState({ targetResult: result });
|
||||
if (!result.featureIds.length) {
|
||||
this.setError("FEATURE_NOT_FOUND");
|
||||
return null;
|
||||
}
|
||||
if (mode !== "highlight") {
|
||||
const features = result.featureIds.map((id) => found.get(id)!);
|
||||
const combined: Feature = features.length === 1 && durationOverride === undefined ? features[0] : {
|
||||
type: "Feature", properties: {},
|
||||
geometry: { type: "MultiPoint", coordinates: features.flatMap((feature) => collectCoordinates(feature.geometry)) }
|
||||
};
|
||||
if (!this.moveCameraToFeature(map, combined, durationOverride)) return null;
|
||||
}
|
||||
if (mode !== "zoom") {
|
||||
this.setHighlights(result.featureIds.map((featureId) => ({ sourceId, featureId })));
|
||||
}
|
||||
this.patchState({ errorCode: null });
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||
@@ -631,15 +657,18 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return collection;
|
||||
}
|
||||
|
||||
private setHighlight(target: FeatureTarget) {
|
||||
const map = this.requireTargetMap(target);
|
||||
private setHighlights(targets: FeatureTarget[]) {
|
||||
const map = this.options.getMap();
|
||||
if (!map) return;
|
||||
if (this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
for (const target of this.state.targets) {
|
||||
if (map.getSource(target.sourceId)) {
|
||||
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
|
||||
}
|
||||
}
|
||||
for (const target of targets) {
|
||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||
this.highlightedTarget = target;
|
||||
this.patchState({ target, errorCode: null });
|
||||
}
|
||||
this.patchState({ target: targets[0] ?? null, targets, errorCode: null });
|
||||
}
|
||||
|
||||
private requireMap() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { GeoJSONSource } from "maplibre-gl";
|
||||
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
|
||||
|
||||
const sourceCount = (page: Page, id: string) => page.evaluate(async (sourceId) => {
|
||||
const source = window.__waterNetworkMap?.getSource(sourceId) as GeoJSONSource | undefined;
|
||||
const data = await source?.getData();
|
||||
return data && typeof data !== "string" && data.type === "FeatureCollection" ? data.features.length : 0;
|
||||
}, id);
|
||||
|
||||
test("More clears controller and investigation overlays while keeping the investigation chart", async ({ page }) => {
|
||||
const task = investigationFixture(78, "清理地图覆盖层验证");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
const presentation = page.getByTestId("investigation-presentation-panel");
|
||||
await expect(presentation.getByText("跟随所选调查", { exact: true })).toBeVisible();
|
||||
await mock.emit(task, { action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" });
|
||||
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(1);
|
||||
await mock.emit(task, { action: "set_overlay", overlay_id: "clear-test", title: "待清理范围", mode: "geometry", evidence_id: "synthetic",
|
||||
data: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.1] } }] } });
|
||||
await expect(presentation.getByRole("region", { name: "调查覆盖层" })).toBeVisible();
|
||||
await mock.emit(task, { action: "open_chart", chart_id: "retain-chart", evidence_refs: ["synthetic"],
|
||||
chart: { title: "保留的调查曲线", chart_type: "line", x_data: ["10:00", "10:15"], series: [{ name: "P1", data: [20, 21] }] } });
|
||||
const chart = presentation.locator('[data-investigation-chart="retain-chart"]');
|
||||
await expect(chart).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const dev = page.getByTestId("map-dev-panel");
|
||||
await dev.getByRole("combobox", { name: "业务源" }).click();
|
||||
await page.getByRole("option", { name: "SCADA", exact: true }).click();
|
||||
await dev.getByLabel("要素 ID(可多个)").fill("P1");
|
||||
await dev.getByRole("button", { name: "仅高亮", exact: true }).click();
|
||||
await dev.locator("summary").filter({ hasText: "水流调试" }).click();
|
||||
await dev.getByRole("button", { name: "开启水流", exact: true }).click();
|
||||
await dev.locator("summary").filter({ hasText: "数值标注" }).click();
|
||||
await dev.getByRole("button", { name: "应用标注", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getFeatureState({ source: "scada", id: "P1" }).highlighted)).toBe(true);
|
||||
await dev.getByRole("button", { name: "关闭 Dev Panel" }).click();
|
||||
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click();
|
||||
const more = page.getByRole("region", { name: "更多", exact: true });
|
||||
await more.getByRole("button", { name: /清空所有高亮和覆盖层/ }).click();
|
||||
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(0);
|
||||
await expect.poll(() => sourceCount(page, "investigation-presentation-overlay")).toBe(0);
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
highlighted: window.__waterNetworkMap?.getFeatureState({ source: "scada", id: "P1" }).highlighted ?? false,
|
||||
flow: window.__waterNetworkMap?.getLayoutProperty("workbench-network-flow-forward", "visibility"),
|
||||
labels: window.__waterNetworkMap?.getLayoutProperty("workbench-value-label-pipes", "visibility")
|
||||
}))).toEqual({ highlighted: false, flow: "none", labels: "none" });
|
||||
await expect(presentation.getByRole("region", { name: "调查覆盖层" })).toHaveCount(0);
|
||||
await expect(chart).toBeVisible();
|
||||
await page.screenshot({ path: "/tmp/map-clear-overlays-desktop.png" });
|
||||
// A later explicit command still works; clearing is a one-time display operation.
|
||||
await mock.emit(task, { action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "steady" });
|
||||
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(1);
|
||||
});
|
||||
|
||||
test("mobile More clears drawing and measurement data and remains usable when already empty", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
await page.getByRole("button", { name: "测量:距离与范围测量", exact: true }).click();
|
||||
await page.getByRole("region", { name: "测量", exact: true }).getByRole("button", { name: /开始测量/ }).click();
|
||||
await page.locator("canvas.maplibregl-canvas").click({ position: { x: 100, y: 175 } });
|
||||
await expect.poll(() => sourceCount(page, "workbench-measurement")).toBeGreaterThan(0);
|
||||
await page.getByRole("button", { name: "标注:管理地图标注", exact: true }).click();
|
||||
await page.getByRole("region", { name: "标注", exact: true }).getByRole("button", { name: "点标注", exact: true }).click();
|
||||
await page.locator("canvas.maplibregl-canvas").click({ position: { x: 100, y: 175 } });
|
||||
await expect.poll(() => sourceCount(page, "workbench-drawing-features")).toBe(1);
|
||||
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click();
|
||||
const more = page.getByRole("region", { name: "更多", exact: true });
|
||||
const clear = more.getByRole("button", { name: /清空所有高亮和覆盖层/ });
|
||||
await expect(clear).toBeVisible();
|
||||
await clear.click();
|
||||
await expect.poll(() => sourceCount(page, "workbench-measurement")).toBe(0);
|
||||
await expect.poll(() => sourceCount(page, "workbench-drawing-features")).toBe(0);
|
||||
await clear.click();
|
||||
await expect(clear).toBeEnabled();
|
||||
const box = await more.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
|
||||
await page.screenshot({ path: "/tmp/map-clear-overlays-mobile.png" });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
for (const width of [1440, 1024]) {
|
||||
test(`batch feature location highlights all matches and reports missing IDs at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await mockInvestigationPresentation(page, []);
|
||||
const queries: string[] = [];
|
||||
await page.route("https://workbench-map.invalid/**/ows?**", (route) => {
|
||||
const filter = new URL(route.request().url()).searchParams.get("cql_filter");
|
||||
if (!filter) return route.fallback();
|
||||
queries.push(filter);
|
||||
const features = [
|
||||
{ type: "Feature", properties: { id: "P-1" }, geometry: { type: "LineString", coordinates: [[121.4, 31.05], [121.42, 31.06]] } },
|
||||
{ type: "Feature", properties: { id: "P-2" }, geometry: { type: "LineString", coordinates: [[121.5, 31.12], [121.52, 31.14]] } }
|
||||
].filter((feature) => filter.includes(`'${feature.properties.id}'`));
|
||||
return route.fulfill({ json: { type: "FeatureCollection", features } });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
const ids = panel.getByRole("textbox", { name: "要素 ID(可多个)" });
|
||||
await ids.fill("P-1,P-2\nP-1 missing");
|
||||
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
|
||||
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveText("本次命中 2 个,未找到 1 个:missing");
|
||||
expect(queries).toEqual(['"id" IN (\'P-1\',\'P-2\',\'missing\')']);
|
||||
const highlights = () => page.evaluate(() => ["P-1", "P-2"].map((id) =>
|
||||
window.__waterNetworkMap?.getFeatureState({ source: "pipes", sourceLayer: "pipes", id }).highlighted ?? false
|
||||
));
|
||||
await expect.poll(highlights).toEqual([true, true]);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const bounds = window.__waterNetworkMap?.getBounds();
|
||||
return bounds?.contains([121.4, 31.05]) && bounds.contains([121.52, 31.14]);
|
||||
})).toBe(true);
|
||||
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((element) => element.clientWidth));
|
||||
await page.screenshot({ path: `/tmp/map-multi-feature-location-${width}.png` });
|
||||
|
||||
await panel.getByRole("button", { name: "清除高亮", exact: true }).click();
|
||||
await expect.poll(highlights).toEqual([false, false]);
|
||||
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: "缩放到要素", exact: true }).click();
|
||||
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toContainText("本次命中 2 个");
|
||||
await expect.poll(highlights).toEqual([false, false]);
|
||||
await panel.getByRole("button", { name: "仅高亮", exact: true }).click();
|
||||
await expect.poll(highlights).toEqual([true, true]);
|
||||
|
||||
await ids.fill("missing");
|
||||
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
|
||||
await expect(panel.getByRole("alert")).toContainText("目标要素不存在");
|
||||
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveText("本次命中 0 个,未找到 1 个:missing");
|
||||
await expect.poll(highlights).toEqual([true, true]);
|
||||
await panel.getByRole("button", { name: "清除高亮", exact: true }).click();
|
||||
await expect.poll(highlights).toEqual([false, false]);
|
||||
await ids.fill(" ");
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮", exact: true })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { OperationalTask } from "../../../src/features/workbench/api/operational-task-client";
|
||||
import { mockAgentApi } from "./mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./mock-workbench-context";
|
||||
|
||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||
const now = "2026-10-27T08:00:00Z";
|
||||
|
||||
export function investigationFixture(index: number, title: string): OperationalTask {
|
||||
const taskId = `92000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
return {
|
||||
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
|
||||
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
|
||||
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",
|
||||
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst_report" },
|
||||
trigger: { kind: "manual", occurred_at: now, detail: "synthetic" }, scheduled_for: null,
|
||||
agent_session_id: "investigation-dev-test", agent_lease_expires_at: now, current_plan_revision: null,
|
||||
current_report_revision: null, created_at: now, updated_at: now,
|
||||
active_run: { run_id: runId, task_id: taskId, generation: 1, execution_request_id: requestId,
|
||||
status: "running", revision: 1, agent_session_id: "investigation-dev-test", lease_expires_at: now,
|
||||
heartbeat_at: now, started_at: now, finished_at: null, failure_code: null, failure_detail: null,
|
||||
dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now },
|
||||
latest_plan: null, latest_report: null,
|
||||
latest_progress: { revision: 1, run_id: runId, execution_generation: 1, published_at: now,
|
||||
source_session_id: "investigation-dev-test", progress: { summary: "正在核对候选管段", diagram: {
|
||||
title: "合成调查", mermaid: 'flowchart TD\nA["核查监测"]', node_refs: [{ node_id: "A", label: "核查监测",
|
||||
status: "running", evidence_refs: [], action_refs: [], map_targets: [{ source_id: "scada", feature_id: "P1" }] }]
|
||||
} } },
|
||||
evidence: [], actions: [], access_requests: [], work_order_snapshot: null,
|
||||
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
|
||||
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false,
|
||||
can_execute_commands: false, can_decide_access: false }
|
||||
};
|
||||
}
|
||||
|
||||
export async function mockInvestigationPresentation(page: Page, tasks: OperationalTask[]) {
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
const mutations: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith("https://tjwater-api.invalid/") && ["POST", "PATCH", "DELETE", "PUT"].includes(request.method()))
|
||||
mutations.push(`${request.method()} ${new URL(request.url()).pathname}`);
|
||||
});
|
||||
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true",
|
||||
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false"
|
||||
})};`
|
||||
}));
|
||||
await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: {
|
||||
items: [{ device_id: "P1", device_type: "pressure", node_id: null, link_id: null, api_query_id: null,
|
||||
measurement_unit: "m", transmission_mode: "test", transmission_frequency: "15min", reliability: 1,
|
||||
x: null, y: null, longitude: 121.48, latitude: 31.10 }], total: 1, limit: 1000, offset: 0
|
||||
} }));
|
||||
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => {
|
||||
return route.fulfill({ json: { schema: "workbench-sync-snapshot@1", event_cursor: 10, generated_at: now,
|
||||
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||
artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured",
|
||||
alert_availability: "not_configured", generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
|
||||
tasks: tasks.map((task) => Object.fromEntries(Object.entries(task).filter(([key]) =>
|
||||
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions"].includes(key)))),
|
||||
notifications: [] } });
|
||||
});
|
||||
await page.route("https://tjwater-api.invalid/api/v1/operational-tasks/**", (route) => {
|
||||
const task = tasks.find((item) => route.request().url().includes(item.task_id));
|
||||
if (!task) return route.fulfill({ status: 404 });
|
||||
if (route.request().url().endsWith("/presentation-cursor")) return route.fulfill({ json: {
|
||||
task_id: task.task_id, execution_generation: task.execution_generation, active_run: {
|
||||
run_id: task.active_run!.run_id, generation: task.execution_generation, status: "running"
|
||||
}, event_cursor: 10
|
||||
} });
|
||||
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
|
||||
return route.fulfill({ json: task });
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
||||
Object.assign(window, { emitInvestigationTestEvent: (event: unknown) => {
|
||||
const data = new TextEncoder().encode(`event: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
for (const stream of streams) stream.enqueue(data);
|
||||
} });
|
||||
window.fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/v1/workbench/events")) return fetch(input, init);
|
||||
let current: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({ start(controller) { current = controller; streams.add(controller); }, cancel() { streams.delete(current); } });
|
||||
init?.signal?.addEventListener("abort", () => { if (streams.delete(current)) current.close(); }, { once: true });
|
||||
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
||||
};
|
||||
});
|
||||
let sequence = 10;
|
||||
return {
|
||||
mutations,
|
||||
emit: async (task: OperationalTask, command: unknown) => page.evaluate((event) => {
|
||||
(window as unknown as { emitInvestigationTestEvent: (event: unknown) => void }).emitInvestigationTestEvent(event);
|
||||
}, { schema: "workbench-event@1", event_seq: ++sequence,
|
||||
event_id: `95000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`, project_id: projectId,
|
||||
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: task.task_id,
|
||||
aggregate_revision: 1, occurred_at: now, metadata: {
|
||||
task_id: task.task_id, run_id: task.active_run!.run_id, execution_generation: task.execution_generation, command
|
||||
} })
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectInvestigation(page: Page, title: string) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const expand = panel.getByRole("button", { name: "展开工况任务" });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
await panel.getByRole("button", { name: /调查任务/ }).click();
|
||||
await panel.getByText(title, { exact: true }).first().click();
|
||||
}
|
||||
Reference in New Issue
Block a user