feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
+25 -12
View File
@@ -1,4 +1,11 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "../../tests/browser/support/mock-agent-api";
import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
});
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
const riveRequests: string[] = [];
@@ -11,6 +18,9 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
await expect(page.getByText("调度工作台")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByLabel("推荐问题")).toBeVisible();
const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__);
@@ -72,8 +82,11 @@ test("renders map notices through a single toaster", async ({ page }) => {
test("preserves compact typography in header and agent controls", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first();
const headerControl = page
.locator("header.acrylic-navigation button.font-semibold:visible")
.first();
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
@@ -86,6 +99,7 @@ test("preserves compact typography in header and agent controls", async ({ page
test("lets interactive utilities override surface materials", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
@@ -113,18 +127,17 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const navigationMaterial = await navigation.evaluate(
(element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
}
);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const navigation = page.getByRole("complementary", { name: "Agent 命令面板" });
const navigationMaterial = await navigation.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
});
expect(navigationMaterial.backdropFilter).toBe("none");
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
expect(navigationMaterial.backgroundColor).toBe("oklch(0.965 0.012 248)");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
});
+14
View File
@@ -122,6 +122,20 @@ describe("Agent API client sessions", () => {
expect(headers.get("Content-Type")).toBe("application/json");
});
it("sends the authorized project context on Agent requests", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getProjectId = vi.fn().mockResolvedValue("project-117");
await createAgentApiClient("http://agent.local", { getProjectId }).listSessions();
expect(getProjectId).toHaveBeenCalledOnce();
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("X-Project-Id")).toBe("project-117");
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
+11 -14
View File
@@ -115,6 +115,7 @@ export type AgentApiClient = {
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
@@ -137,7 +138,8 @@ export function createAgentApiClient(
setActiveBaseUrl,
path,
init,
options.getAccessToken
options.getAccessToken,
options.getProjectId
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
@@ -298,18 +300,19 @@ async function fetchWithFallback(
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string>
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId);
const headers = hasContextHeaders ? new Headers(init?.headers) : undefined;
if (accessToken) headers?.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) {
try {
@@ -337,12 +340,6 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
@@ -20,7 +20,17 @@ import {
X
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction
} from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
@@ -108,7 +118,9 @@ type AgentCommandPanelProps = {
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
onCollapse: () => void;
onPromptChange: Dispatch<SetStateAction<string>>;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
@@ -160,7 +172,9 @@ export function AgentCommandPanel({
approvalMode = "request",
uiResults = [],
workspaceContext = [],
prompt,
onCollapse,
onPromptChange,
onRefreshHistory,
onStartNewSession,
onLoadHistorySession,
@@ -174,13 +188,14 @@ export function AgentCommandPanel({
onReplyQuestion,
onRejectQuestion
}: AgentCommandPanelProps) {
const [prompt, setPrompt] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const historyPanelId = useId();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const promptRestoreGenerationRef = useRef(0);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
@@ -195,8 +210,9 @@ export function AgentCommandPanel({
isSupported: isSpeechSupported
} = useAgentSpeechSynthesis();
const appendRecognitionResult = useCallback((text: string) => {
setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`);
}, []);
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
}, [onPromptChange]);
const showRecognitionError = useCallback((message: string) => {
showMapNotice({
id: "agent-speech-recognition-error",
@@ -219,6 +235,14 @@ export function AgentCommandPanel({
}
}, [historyOpen, onRefreshHistory]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
promptRestoreGenerationRef.current += 1;
};
}, []);
useEffect(() => {
if (!historyOpen) {
return;
@@ -271,11 +295,16 @@ export function AgentCommandPanel({
if (!nextPrompt || streaming) {
return;
}
setPrompt("");
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
setPrompt(nextPrompt);
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
return;
}
onPromptChange((current) => current || nextPrompt);
});
};
const historyPanel = (
@@ -286,6 +315,7 @@ export function AgentCommandPanel({
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
promptRestoreGenerationRef.current += 1;
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
@@ -369,8 +399,9 @@ export function AgentCommandPanel({
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
promptRestoreGenerationRef.current += 1;
setHistoryOpen(false);
setPrompt("");
onPromptChange("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
@@ -436,7 +467,9 @@ export function AgentCommandPanel({
{workspaceContext.length > 0 ? (
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">
当前上下文
</span>
{workspaceContext.map((item) => (
<span
key={item.id}
@@ -532,7 +565,12 @@ export function AgentCommandPanel({
) : (
<motion.div
key="empty"
className="flex flex-1 items-center justify-center px-4 py-8"
className={cn(
"flex flex-1 px-4",
presentation === "mobile-sheet"
? "items-start justify-center pb-0 pt-5"
: "items-center justify-center py-8"
)}
initial="initial"
animate="animate"
exit="exit"
@@ -605,7 +643,10 @@ export function AgentCommandPanel({
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
@@ -976,13 +1017,15 @@ function AgentModelSelect({
type="button"
variant="outline"
size="sm"
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-slate-50 sm:w-24 sm:gap-1.5 sm:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
<span className="shrink-0">{selectedModel?.label ?? "模型"}</span>
<span className="hidden shrink-0 sm:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
</Button>
@@ -1104,41 +1147,53 @@ function AgentReadyMark() {
}
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
lang="zh-CN"
>
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-sm font-semibold leading-5 text-slate-950"
>
我已就绪,请描述任务
</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-600">
描述调查目标,我会整理证据并带回地图。
</p>
</div>
</section>
);
}
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
<div className="px-2 py-4">
<div className="flex items-center gap-4">
<div
className={cn(
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
)}
>
<div className="surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className={cn(
"text-balance font-semibold text-slate-950",
compact ? "text-base leading-6" : "text-lg leading-7"
)}
className="text-balance text-lg font-semibold leading-7 text-slate-950"
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p
className={cn(
"text-pretty text-left text-sm leading-6 text-[#334155]",
compact ? "mt-3" : "mt-5"
)}
>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
@@ -16,6 +16,7 @@ export type MapControlPanelProps = {
description?: string;
icon: LucideIcon;
widthClassName?: string;
contentClassName?: string;
visible?: boolean;
children: ReactNode;
};
@@ -25,6 +26,7 @@ export function MapControlPanel({
description,
icon: Icon,
widthClassName = "w-[292px]",
contentClassName,
visible = true,
children
}: MapControlPanelProps) {
@@ -51,7 +53,7 @@ export function MapControlPanel({
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
</div>
</div>
<div className="relative space-y-3">{children}</div>
<div className={cn("relative space-y-3", contentClassName)}>{children}</div>
</section>
);
}
@@ -11,9 +11,26 @@ export type MapLayerControlItem = {
id: string;
label: string;
description?: string;
featureCount?: number;
minZoom?: number;
visible: boolean;
disabled?: boolean;
locked?: boolean;
group?: string;
groupLabel?: string;
groupDescription?: string;
groupDefaultOpen?: boolean;
collection?: string;
collectionLabel?: string;
visual?: MapLayerControlVisual;
};
export type MapLayerControlVisual = {
kind: "line" | "line-stack" | "point" | "ring" | "area" | "image";
color?: string;
colors?: string[];
dashed?: boolean;
imageSrc?: string;
};
type MapLayerControlProps = {
+237 -42
View File
@@ -1,7 +1,8 @@
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
import { Check, ChevronRight, Eye, EyeOff, Lock, Map as MapIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem } from "./layer-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -43,50 +44,244 @@ type LayerVisibilitySectionProps = {
};
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
return (
<div>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
<div className="grid grid-cols-3 gap-2">
{items.map((item) => {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
const groups = groupLayerItems(items);
const [openGroupIds, setOpenGroupIds] = useState(
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
);
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-pressed={item.visible}
aria-label={`${item.label}${item.description ? `,${item.description}` : ""}`}
title={item.description}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center transition-colors",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
<span className="block w-full truncate text-xs font-semibold leading-tight">{item.label}</span>
</button>
);
})}
</div>
const handleGroupToggle = (groupId: string, open: boolean) => {
setOpenGroupIds((current) => {
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
return next;
});
};
return (
<div className="space-y-2">
{groups.map((group) => (
<details
key={group.id}
open={openGroupIds.has(group.id)}
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border"
>
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]">
<ChevronRight
size={14}
aria-hidden="true"
className="shrink-0 text-slate-400 transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500">
{group.description}
</span>
) : null}
</span>
<span className="shrink-0 rounded-full bg-white/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-500 shadow-xs shadow-slate-950/5">
{group.items.filter((item) => item.visible).length}/{group.items.length}
</span>
</summary>
<div className="border-t border-slate-200/70 bg-white/45 p-1.5">
{groupLayerItemsByCollection(group.items).map((collection) => (
<div key={collection.id} className="not-first:mt-2.5">
{collection.label ? (
<div className="mb-1 flex items-center gap-2 px-1.5 text-[10px] font-medium text-slate-500">
<span className="truncate">{collection.label}</span>
<span className="h-px min-w-3 flex-1 bg-slate-200/80" aria-hidden="true" />
<span className="shrink-0 tabular-nums">{collection.items.length} 层</span>
</div>
) : null}
<div className="space-y-1">
{collection.items.map((item) => (
<LayerVisibilityRow
key={item.id}
item={item}
onToggleLayer={onToggleLayer}
/>
))}
</div>
</div>
))}
</div>
</details>
))}
</div>
);
}
function groupLayerItems(items: MapLayerControlItem[]) {
const groups = new Map<string, {
id: string;
label: string;
description?: string;
defaultOpen: boolean;
items: MapLayerControlItem[];
}>();
items.forEach((item) => {
const id = item.group ?? "business";
const existing = groups.get(id);
if (existing) {
existing.items.push(item);
return;
}
groups.set(id, {
id,
label: item.groupLabel ?? "业务图层",
description: item.groupDescription,
defaultOpen: item.groupDefaultOpen ?? true,
items: [item]
});
});
return [...groups.values()];
}
function groupLayerItemsByCollection(items: MapLayerControlItem[]) {
const collections = new Map<string, {
id: string;
label?: string;
items: MapLayerControlItem[];
}>();
items.forEach((item) => {
const id = item.collection ?? "default";
const existing = collections.get(id);
if (existing) {
existing.items.push(item);
return;
}
collections.set(id, {
id,
label: item.collectionLabel,
items: [item]
});
});
return [...collections.values()];
}
function LayerVisibilityRow({
item,
onToggleLayer
}: {
item: MapLayerControlItem;
onToggleLayer: (id: string, visible: boolean) => void;
}) {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
const meta = formatLayerMeta(item);
return (
<button
type="button"
disabled={disabled}
aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200/90 bg-white text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-slate-100/55 text-slate-600 [@media(hover:hover)]:hover:border-slate-200 [@media(hover:hover)]:hover:bg-white/75",
disabled && "cursor-not-allowed opacity-55 active:scale-100"
)}
>
<LayerVisual visual={item.visual} visible={item.visible} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-tight">{item.label}</span>
{meta ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500">
{meta}
</span>
) : null}
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-blue-50 text-blue-700" : "bg-transparent text-slate-400"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
</button>
);
}
function formatLayerMeta(item: MapLayerControlItem) {
const details = [item.description];
if (item.featureCount !== undefined) details.push(formatFeatureCount(item.featureCount));
if (item.minZoom !== undefined) details.push(`${item.minZoom} 级起`);
return details.filter(Boolean).join(" · ");
}
function formatFeatureCount(count: number) {
return `${new Intl.NumberFormat("zh-CN", {
notation: count >= 10_000 ? "compact" : "standard",
maximumFractionDigits: 1
}).format(count)} 要素`;
}
function LayerVisual({
visual,
visible
}: {
visual?: MapLayerControlVisual;
visible: boolean;
}) {
const color = visual?.color ?? "#64748B";
return (
<span
aria-hidden="true"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-slate-50 shadow-inner shadow-slate-950/5 transition-opacity duration-150 motion-reduce:transition-none",
!visible && "opacity-45"
)}
>
{visual?.kind === "image" && visual.imageSrc ? (
<img src={visual.imageSrc} alt="" width={24} height={24} />
) : visual?.kind === "line-stack" ? (
<svg width="26" height="22" viewBox="0 0 26 22">
{(visual.colors?.length ? visual.colors : [color, color, color]).slice(0, 3).map((lineColor, index) => (
<g key={`${lineColor}-${index}`}>
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke="white" strokeWidth={4 + index} strokeLinecap="round" />
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke={lineColor} strokeWidth={1.5 + index * 0.7} strokeLinecap="round" />
</g>
))}
</svg>
) : visual?.kind === "line" ? (
<svg width="26" height="12" viewBox="0 0 26 12">
<line x1="2" y1="6" x2="24" y2="6" stroke="white" strokeWidth="6" strokeLinecap="round" />
<line
x1="2"
y1="6"
x2="24"
y2="6"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={visual.dashed ? "5 3" : undefined}
/>
</svg>
) : visual?.kind === "ring" ? (
<span className="h-3.5 w-3.5 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />
) : visual?.kind === "area" ? (
<span
className="h-4 w-5 rounded-[4px] border"
style={{ backgroundColor: `${color}24`, borderColor: color }}
/>
) : (
<span
className="h-3.5 w-3.5 rounded-full"
style={{ backgroundColor: color, boxShadow: `0 0 0 2px white, 0 0 0 3px ${color}` }}
/>
)}
</span>
);
}
type BaseLayerSectionProps = {
options: BaseLayerOption[];
activeLayerId: string;
@@ -98,7 +293,7 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
<div>
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-slate-500">底图</span>
<Map size={14} aria-hidden="true" className="text-slate-400" />
<MapIcon size={14} aria-hidden="true" className="text-slate-400" />
</div>
<div className="grid grid-cols-3 gap-2">
{options.map((option) => {
+135 -33
View File
@@ -1,11 +1,8 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusDot } from "@/shared/ui/status";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
@@ -14,9 +11,15 @@ export type MapLegendItem = {
label: string;
description?: string;
color: string;
layerControlId?: string;
group?: string;
groupLabel?: string;
groupDescription?: string;
groupLayout?: "list" | "grid";
icon?: LucideIcon;
imageSrc?: string;
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
lineWidth?: number;
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square" | "area";
};
type MapLegendProps = {
@@ -37,7 +40,7 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
)}
>
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
<p className="mt-0.5 px-1 text-xs text-slate-500">当前地图符号与分级</p>
<MapLegendList items={items} className="mt-3" />
</section>
);
@@ -46,44 +49,134 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
type MapLegendListProps = {
items: MapLegendItem[];
className?: string;
showStatusDot?: boolean;
};
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
return (
<ul className={cn("space-y-1.5", className)}>
{items.map((item) => {
const Icon = item.icon;
export function MapLegendList({ items, className = "" }: MapLegendListProps) {
const groups = groupLegendItems(items);
return (
<li
key={item.id}
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
</span>
if (groups.length === 0) {
return (
<div className={cn("surface-well rounded-xl border px-3 py-4 text-center", className)}>
<p className="text-xs font-semibold text-slate-700">暂无可见业务图层</p>
<p className="mt-1 text-[11px] leading-relaxed text-slate-500">请先在图层面板中打开需要查看的内容。</p>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
{groups.map((group) => (
<section
key={group.id}
aria-label={group.label}
className="surface-well overflow-hidden rounded-xl border"
>
<div className="flex min-h-10 items-center gap-2 px-2.5 py-1.5">
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500">
{group.description}
</span>
) : null}
</span>
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
</li>
);
})}
</ul>
<span className="shrink-0 rounded-full bg-white/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-500 shadow-xs shadow-slate-950/5">
{group.items.length} 类
</span>
</div>
<ul
className={cn(
"border-t border-slate-200/70",
group.layout === "grid"
? "grid grid-cols-2 gap-px bg-slate-200/70"
: "divide-y divide-slate-200/70 bg-white/45"
)}
>
{group.items.map((item) => (
<LegendItem key={item.id} item={item} compact={group.layout === "grid"} />
))}
</ul>
</section>
))}
</div>
);
}
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
if (shape === "line") {
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
}
function groupLegendItems(items: MapLegendItem[]) {
const groups = new Map<string, {
id: string;
label: string;
description?: string;
layout: "list" | "grid";
items: MapLegendItem[];
}>();
items.forEach((item) => {
const id = item.group ?? "legend";
const existing = groups.get(id);
if (existing) {
existing.items.push(item);
return;
}
groups.set(id, {
id,
label: item.groupLabel ?? "地图符号",
description: item.groupDescription,
layout: item.groupLayout ?? "list",
items: [item]
});
});
return [...groups.values()];
}
if (shape === "dashed-line" || shape === "dash-dot-line") {
function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }) {
const Icon = item.icon;
return (
<li
className={cn(
"flex items-center text-slate-700",
compact ? "min-h-11 gap-2 bg-white/70 px-2 py-1.5" : "min-h-9 gap-2.5 px-2.5 py-1.5"
)}
>
<span
aria-hidden="true"
className={cn(
"grid shrink-0 place-items-center bg-slate-50 shadow-inner shadow-slate-950/5",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
compact ? "h-7 w-7" : "h-7 w-9"
)}
>
{item.imageSrc ? (
<img src={item.imageSrc} alt="" width={24} height={24} />
) : Icon ? (
<Icon size={15} style={{ color: item.color }} />
) : (
<MapLegendSwatch color={item.color} shape={item.shape} lineWidth={item.lineWidth} />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-semibold leading-tight">{item.label}</span>
{compact && item.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500">{item.description}</span>
) : null}
</span>
{!compact && item.description ? (
<span className="shrink-0 text-[10px] text-slate-500">{item.description}</span>
) : null}
</li>
);
}
export function MapLegendSwatch({
color,
shape = "dot",
lineWidth = 3
}: Pick<MapLegendItem, "color" | "shape" | "lineWidth">) {
if (shape === "line" || shape === "dashed-line" || shape === "dash-dot-line") {
const dashArray = shape === "dashed-line" ? "5 3" : shape === "dash-dot-line" ? "8 3 2 3" : undefined;
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden="true">
<line x1="1" y1="4" x2="23" y2="4" stroke={color} strokeWidth="3" strokeDasharray={shape === "dashed-line" ? "5 3" : "8 3 2 3"} />
<svg width="28" height="12" viewBox="0 0 28 12" aria-hidden="true">
<line x1="2" y1="6" x2="26" y2="6" stroke="white" strokeWidth={lineWidth + 3} strokeLinecap="round" strokeDasharray={dashArray} />
<line x1="2" y1="6" x2="26" y2="6" stroke={color} strokeWidth={lineWidth} strokeLinecap="round" strokeDasharray={dashArray} />
</svg>
);
}
@@ -92,6 +185,15 @@ export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "c
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
}
if (shape === "area") {
return (
<span
className="h-4 w-5 rounded-[4px] border"
style={{ backgroundColor: `${color}24`, borderColor: color }}
/>
);
}
if (shape === "ring") {
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
}
@@ -1,13 +1,9 @@
import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { ChevronRight, Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapModeButton,
MapPanelSection
} from "./control-panel";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
@@ -36,12 +32,18 @@ export type MapMeasureAction = {
description: string;
icon?: LucideIcon;
strong?: boolean;
variant?: "default" | "primary";
variant?: "default" | "primary" | "active";
tone?: "default" | "danger";
disabled?: boolean;
onClick?: () => void;
};
export type MapMeasureStatus = {
label: string;
description: string;
tone?: "idle" | "active" | "ready" | "disabled";
};
export type MapMeasurePanelProps = {
modes: MapMeasureMode[];
activeModeId: string;
@@ -52,6 +54,7 @@ export type MapMeasurePanelProps = {
units: MapMeasureUnit[];
activeUnitId: string;
actions: MapMeasureAction[];
status: MapMeasureStatus;
onSelectMode?: (id: string) => void;
onSelectUnit?: (id: string) => void;
};
@@ -66,90 +69,227 @@ export function MapMeasurePanel({
units,
activeUnitId,
actions,
status,
onSelectMode,
onSelectUnit
}: MapMeasurePanelProps) {
const primaryAction = actions[0];
const secondaryActions = actions.slice(1);
return (
<div className="space-y-3">
<MapPanelSection title="模式">
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
<section>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500 max-lg:hidden">
测量类型
</div>
<div
role="group"
aria-label="测量类型"
className={cn("surface-well grid grid-cols-3 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
>
{modes.map((mode) => (
<MapModeButton
<button
key={mode.id}
icon={mode.icon}
label={mode.label}
active={mode.id === activeModeId}
type="button"
aria-pressed={mode.id === activeModeId}
disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)}
/>
className={cn(
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId
? "bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-white/85 [@media(hover:hover)]:hover:text-blue-700",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100"
)}
>
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span className="truncate">{mode.label}</span>
</button>
))}
</div>
</MapPanelSection>
</section>
<MapPanelSection title="结果">
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="min-w-0 border-b border-slate-100 pb-3">
<section
aria-label="测量读数"
className={cn("overflow-hidden", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}
>
<div className="px-3.5 py-2.5">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 pt-0.5">
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
<div className="mt-1.5 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums">
{resultValue}
</span>
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
</div>
</div>
{metrics.length > 0 ? (
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
{metrics.map((metric) => (
<div
key={metric.label}
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<div className="truncate text-xs text-slate-500">{metric.label}</div>
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
</div>
))}
</div>
) : null}
</div>
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
{units.map((unit) => (
<button
key={unit.id}
type="button"
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-10 text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
: "surface-control text-slate-600 hover:text-blue-700"
)}
>
{unit.label}
</button>
))}
<div
role="group"
aria-label="测量单位"
className={cn("surface-well grid shrink-0 grid-cols-2 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
>
{units.map((unit) => (
<button
key={unit.id}
type="button"
aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-white/85 [@media(hover:hover)]:hover:text-blue-700"
)}
>
{unit.label}
</button>
))}
</div>
</div>
</div>
</MapPanelSection>
<MapPanelSection title="操作">
{actions.map((action) => (
<MapActionRow
key={action.id}
icon={action.icon ?? getDefaultActionIcon(action.id)}
label={action.label}
description={action.description}
strong={action.strong}
variant={action.variant}
tone={action.tone}
disabled={action.disabled}
onClick={action.onClick}
{metrics.length > 0 ? (
<dl className="grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] border-t border-slate-200/80">
{metrics.map((metric, index) => (
<div
key={metric.label}
className={cn("min-w-0 px-3.5 py-2", index > 0 && "border-l border-slate-200/80")}
>
<dt className="truncate text-[11px] text-slate-500">{metric.label}</dt>
<dd className="mt-0.5 truncate text-sm font-semibold tabular-nums text-slate-900">
{metric.value}
</dd>
</div>
))}
</dl>
) : null}
<div
role="status"
className={cn(
"flex min-h-10 items-center gap-2.5 border-t px-3.5 py-2",
status.tone === "active"
? "border-blue-100 bg-blue-50/90"
: status.tone === "ready"
? "border-emerald-100 bg-emerald-50/80"
: "border-slate-200/80 bg-slate-50/75"
)}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
status.tone === "active"
? "bg-blue-600 motion-safe:animate-pulse"
: status.tone === "ready"
? "bg-emerald-500"
: status.tone === "disabled"
? "bg-amber-400"
: "bg-slate-400"
)}
aria-hidden="true"
/>
))}
</MapPanelSection>
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold text-slate-800">{status.label}</div>
<div className="truncate text-[11px] text-slate-500">{status.description}</div>
</div>
</div>
</section>
{primaryAction ? (
<section aria-label="测量操作" className="space-y-2">
<MeasureActionButton action={primaryAction} primary />
{secondaryActions.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{secondaryActions.map((action) => (
<MeasureActionButton key={action.id} action={action} />
))}
</div>
) : null}
</section>
) : null}
</div>
);
}
function MeasureActionButton({
action,
primary = false
}: {
action: MapMeasureAction;
primary?: boolean;
}) {
const Icon = action.icon ?? getDefaultActionIcon(action.id);
const danger = action.tone === "danger";
const active = action.variant === "active";
return (
<button
type="button"
title={action.description}
data-state={active ? "active" : undefined}
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none",
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
MAP_COMPACT_RADIUS_CLASS_NAME,
primary
? active
? "border-blue-300 bg-white text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-blue-100 [@media(hover:hover)]:hover:border-blue-400 [@media(hover:hover)]:hover:bg-blue-50/70"
: action.variant === "primary"
? "border-slate-200 bg-slate-50/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-slate-300 [@media(hover:hover)]:hover:bg-white"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn(
MAP_READABLE_SURFACE_CLASS_NAME,
danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50"
: "text-slate-700 [@media(hover:hover)]:hover:border-blue-200 [@media(hover:hover)]:hover:text-blue-700"
),
action.disabled &&
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-slate-200 [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700"
)}
>
{primary ? (
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active
? "bg-blue-600 text-white"
: "bg-white text-slate-700 ring-1 ring-inset ring-slate-200"
)}
>
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
</span>
) : (
<Icon size={15} strokeWidth={2} aria-hidden="true" />
)}
{primary ? (
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-slate-500">
{action.description}
</span>
</span>
) : (
<span>{action.label}</span>
)}
{primary ? (
active ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-semibold text-blue-700 ring-1 ring-inset ring-blue-200">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 motion-safe:animate-pulse" aria-hidden="true" />
进行中
</span>
) : (
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-slate-400" />
)
) : null}
</button>
);
}
function getDefaultActionIcon(actionId: string) {
if (actionId.includes("copy")) {
return Copy;
+5 -1
View File
@@ -21,7 +21,11 @@ export {
type MapDrawToolbarProps,
type MapDrawTool
} from "./components/draw-toolbar";
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
export {
MapLayerControl,
type MapLayerControlItem,
type MapLayerControlVisual
} from "./components/layer-control";
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export {
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchElementGisProperties } from "./gis-properties-client";
describe("GIS properties client", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads validated attributes for the selected project element", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
element_type: "pipes",
element_id: "P/117",
groups: [{
dataset_key: "fx_pipe_inventory_20200811",
dataset_title: "奉贤 DN100 及以上管网台账",
source_date: "2020-08-11",
layer_key: "fx_20200811_dn100_pipes",
layer_name: "DN100 及以上管线台账",
source_fid: 117,
match_method: "geometry",
match_distance_m: 0.12,
attributes: { PIPE_D: 300, MATERIAL: "DI" }
}]
}), { status: 200, headers: { "Content-Type": "application/json" } }));
const groups = await fetchElementGisProperties(
"pipes",
"P/117",
"project-117",
async () => "read-token"
);
expect(groups[0].attributes).toEqual({ PIPE_D: 300, MATERIAL: "DI" });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/elements/pipes/P%2F117/gis-properties");
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-117");
});
});
@@ -0,0 +1,114 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
dataset_title: z.string().min(1),
source_date: z.string().date(),
layer_key: z.string().min(1),
layer_name: z.string().min(1),
source_fid: z.number().int(),
match_method: z.enum(["node_pair", "geometry", "source"]),
match_distance_m: z.number().finite().nonnegative().nullable(),
attributes: z.record(z.unknown())
});
const elementGisPropertiesSchema = z.object({
element_type: z.enum([
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada"
]),
element_id: z.string().min(1),
groups: z.array(gisAttributeGroupSchema)
});
const sourceGisFeaturePropertiesSchema = z.object({
layer_key: z.string().min(1),
source_fid: z.number().int().nonnegative(),
group: gisAttributeGroupSchema
});
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
elementId: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
}
const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
}
return result.groups;
}
export async function fetchSourceGisFeatureProperties(
layerKey: string,
sourceFid: number,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
"features",
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
}
const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致");
}
return [result.group];
}
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchProjectPresentation,
findOwnedWorkbenchProject,
parseProjectMapExtent
} from "./project-client";
describe("workbench project client", () => {
afterEach(() => vi.unstubAllGlobals());
it("selects the active project owned by the configured GeoServer workspace", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
items: [
{ project_id: "inactive", gs_workspace: "tjwater_next", status: "disabled" },
{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }
],
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const result = await findOwnedWorkbenchProject(async () => "token");
expect(result.project.project_id).toBe("supply");
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBeNull();
});
it("does not infer a project when the authorized list has no workspace match", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
items: [], total: 0, limit: 1000, offset: 0
}), { status: 200 })));
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("loads the authoritative project extent and access identity independently", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
if (path === "/api/v1/projects") {
return new Response(JSON.stringify({
items: [{
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
status: "active",
project_role: "member"
}],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 });
}
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBe("supply");
if (path === "/api/v1/projects/current/metadata") {
return new Response(JSON.stringify({
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
map_extent: { bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23], zoom: 13 },
status: "active",
project_role: "member"
}), { status: 200 });
}
return new Response(JSON.stringify({
user_id: "user-1",
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "supply",
project_role: "member",
permissions: ["webgis.view"]
}), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const presentation = await fetchWorkbenchProjectPresentation(async () => "token");
expect(presentation).toMatchObject({
projectId: "supply",
projectName: "北区供水项目",
username: "north.operator",
projectRole: "member",
metadataAvailable: true,
accessContextAvailable: true,
networkView: {
bbox3857: [13358338.895, 3503549.844, 13369470.85, 3516410.23]
}
});
});
it("rejects malformed or reversed project extents without losing the fallback path", () => {
expect(parseProjectMapExtent({ bbox: [10, 20, 5, 30] })).toBeNull();
expect(parseProjectMapExtent({ bbox: [0, 0, 30_000_000, 1] })).toBeNull();
expect(parseProjectMapExtent({ xmin: 1, ymin: 2, xmax: 3, ymax: 4 })).toEqual({
bbox3857: [1, 2, 3, 4]
});
});
});
@@ -0,0 +1,196 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
const projectSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1).optional(),
code: z.string().trim().min(1).optional(),
gs_workspace: z.string().min(1),
status: z.string(),
project_role: z.string().trim().min(1).optional()
});
const projectPageSchema = z.object({
items: z.array(projectSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
export type WorkbenchProject = z.infer<typeof projectSchema>;
const projectMetadataSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1),
code: z.string().trim().min(1),
gs_workspace: z.string().trim().min(1),
map_extent: z.unknown().nullable().optional(),
status: z.string(),
project_role: z.string().trim().min(1)
});
const accessContextSchema = z.object({
user_id: z.string().min(1),
username: z.string().trim().min(1),
system_role: z.string().trim().min(1),
is_system_admin: z.boolean(),
project_id: z.string().min(1).nullable().optional(),
project_role: z.string().trim().min(1).nullable().optional(),
permissions: z.array(z.string())
});
const bboxSchema = z.tuple([
z.number().finite(),
z.number().finite(),
z.number().finite(),
z.number().finite()
]);
const keyedExtentSchema = z.object({
xmin: z.number().finite(),
ymin: z.number().finite(),
xmax: z.number().finite(),
ymax: z.number().finite()
});
export type WorkbenchProjectPresentation = {
projectId: string;
projectName: string | null;
username: string | null;
projectRole: string | null;
systemRole: string | null;
isSystemAdmin: boolean;
networkView: NetworkViewParams | null;
metadataAvailable: boolean;
accessContextAvailable: boolean;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const projects = projectPageSchema.parse(payload);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return { project, accessToken };
}
export async function fetchWorkbenchProjectPresentation(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchProjectPresentation> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const headers = createWorkbenchHeaders(accessToken, project.project_id);
const [metadataResult, accessResult] = await Promise.allSettled([
fetchProjectMetadata(project, headers, signal),
fetchAccessContext(project, headers, signal)
]);
const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : null;
const accessContext = accessResult.status === "fulfilled" ? accessResult.value : null;
return {
projectId: project.project_id,
projectName: metadata?.name ?? project.name ?? null,
username: accessContext?.username ?? null,
projectRole:
accessContext?.project_role
?? metadata?.project_role
?? project.project_role
?? null,
systemRole: accessContext?.system_role ?? null,
isSystemAdmin: accessContext?.is_system_admin ?? false,
networkView: parseProjectMapExtent(metadata?.map_extent),
metadataAvailable: metadata !== null,
accessContextAvailable: accessContext !== null
};
}
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
let bbox: z.infer<typeof bboxSchema> | null = null;
if (value && typeof value === "object" && "bbox" in value) {
const parsed = bboxSchema.safeParse(value.bbox);
if (parsed.success) bbox = parsed.data;
} else {
const parsed = keyedExtentSchema.safeParse(value);
if (parsed.success) {
bbox = [parsed.data.xmin, parsed.data.ymin, parsed.data.xmax, parsed.data.ymax];
}
}
if (!bbox || bbox[0] >= bbox[2] || bbox[1] >= bbox[3]) return null;
const webMercatorLimit = 20_037_508.35;
if (bbox.some((coordinate) => Math.abs(coordinate) > webMercatorLimit)) return null;
return { bbox3857: bbox };
}
async function fetchProjectMetadata(
project: WorkbenchProject,
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
}
const metadata = projectMetadataSchema.parse(payload);
if (metadata.project_id !== project.project_id) {
throw new Error("项目元数据与授权项目不一致");
}
return metadata;
}
async function fetchAccessContext(
project: WorkbenchProject,
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
}
const accessContext = accessContextSchema.parse(payload);
if (accessContext.project_id !== project.project_id) {
throw new Error("访问上下文与授权项目不一致");
}
return accessContext;
}
export function createWorkbenchHeaders(
accessToken: string | null | undefined,
projectId?: string,
json = false
) {
const headers = new Headers({ Accept: "application/json" });
if (json) headers.set("Content-Type", "application/json");
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers.set("X-Project-Id", projectId);
return headers;
}
export function getServerError(payload: unknown, status: number, operation: string) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String(payload.detail)
: `HTTP ${status}`;
return `${operation}失败:${detail}`;
}
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchRealtimePipeFlows } from "./realtime-flow-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
describe("realtime pipe flow client", () => {
afterEach(() => vi.unstubAllGlobals());
it("keeps the latest finite velocity for each backend pipe", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ time: "2026-09-17T02:25:00Z", link_id: "P1", velocity: -0.3 },
{ time: "2026-09-17T02:29:00Z", link_id: "P1", velocity: 0.4 },
{ time: "2026-09-17T02:28:00Z", link_id: "P2", velocity: 0 },
{ time: "2026-09-17T02:29:00Z", link_id: "P3", velocity: null }
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchRealtimePipeFlows(
vi.fn().mockResolvedValue("access-token"),
undefined,
new Date("2026-09-17T02:30:00Z")
);
expect(snapshot).toEqual({
observedAt: "2026-09-17T02:29:00Z",
records: [
{ pipeId: "P1", velocity: 0.4 },
{ pipeId: "P2", velocity: 0 }
]
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"start_time=2026-09-17T02%3A15%3A00.000Z"
);
});
it("rejects malformed rows so the caller can keep the mock fallback", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ time: "2026-09-17T02:29:00Z", link_id: "P1", velocity: "fast" }
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchRealtimePipeFlows()).rejects.toThrow();
});
});
@@ -0,0 +1,65 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }),
link_id: z.union([z.string().trim().min(1), z.number()]),
velocity: z.number().finite().nullable()
});
const realtimeLinksSchema = z.array(realtimeLinkSchema);
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = {
observedAt: string;
records: PipeFlowCalculation[];
};
export async function fetchRealtimePipeFlows(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal,
now = new Date()
): Promise<RealtimePipeFlowSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString()
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "实时管道流速请求"));
}
const rows = realtimeLinksSchema.parse(payload);
const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>();
rows.forEach((row) => {
if (row.velocity === null) return;
const pipeId = String(row.link_id);
const current = latestByPipe.get(pipeId);
if (!current || Date.parse(row.time) >= Date.parse(current.time)) {
latestByPipe.set(pipeId, row);
}
});
const latestRows = [...latestByPipe.values()];
return {
observedAt: latestRows.reduce(
(latest, row) => Date.parse(row.time) > Date.parse(latest) ? row.time : latest,
latestRows[0]?.time ?? now.toISOString()
),
records: latestRows.map((row) => ({
pipeId: row.link_id,
velocity: row.velocity as number
}))
};
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchScadaDeviceCollection } from "./scada-client";
import { fetchScadaDeviceCollection, fetchScadaSnapshot } from "./scada-client";
const device = {
device_id: "SCADA-1",
@@ -7,6 +7,7 @@ const device = {
node_id: "J-1",
link_id: null,
api_query_id: "pressure-query",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
@@ -35,7 +36,15 @@ describe("SCADA API client", () => {
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{
time: "2026-09-17T02:29:00Z",
device_id: "SCADA-1",
monitored_value: 0.31,
cleaned_value: 0.3
}
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("access-token");
@@ -45,17 +54,57 @@ describe("SCADA API client", () => {
expect.objectContaining({
id: "SCADA-1",
geometry: { type: "Point", coordinates: [121.5, 30.9] },
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
properties: expect.objectContaining({
device_id: "SCADA-1",
node_id: "J-1",
latest_value: 0.31,
latest_value_with_unit: "0.31 MPa",
latest_observed_at: "2026-09-17T02:29:00Z",
latest_value_source: "monitored_value"
})
})
]);
expect(getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(3);
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain("/api/v1/timeseries/scada-readings?");
});
it("keeps real device metadata when realtime readings are unavailable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [device],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "TimescaleDB unavailable" }), {
status: 503
}));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchScadaSnapshot(
undefined,
undefined,
new Date("2026-09-17T02:30:00Z")
);
expect(snapshot.telemetryStatus).toBe("unavailable");
expect(snapshot.observedAt).toBeNull();
expect(snapshot.collection.features).toHaveLength(1);
expect(snapshot.collection.features[0]?.properties).not.toHaveProperty("latest_value");
});
it("does not query devices when no active project matches the workspace", async () => {
+123 -35
View File
@@ -1,20 +1,8 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
const projectSchema = z.object({
project_id: z.string().min(1),
gs_workspace: z.string().min(1),
status: z.string()
});
const projectPageSchema = z.object({
items: z.array(projectSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
@@ -22,6 +10,7 @@ const scadaDeviceSchema = z.object({
node_id: z.string().nullable(),
link_id: z.string().nullable(),
api_query_id: z.string().nullable(),
measurement_unit: z.string(),
transmission_mode: z.string(),
transmission_frequency: z.string(),
reliability: z.number().int().nullable(),
@@ -38,24 +27,41 @@ const scadaDevicePageSchema = z.object({
offset: z.number().int().nonnegative()
});
const scadaReadingSchema = z.object({
time: z.string().datetime({ offset: true }),
device_id: z.string().trim().min(1),
monitored_value: z.number().finite().nullable(),
cleaned_value: z.number().finite().nullable()
});
const scadaReadingsSchema = z.array(scadaReadingSchema);
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
export type ScadaSnapshot = {
collection: FeatureCollection<Point, ScadaDevice>;
observedAt: string | null;
observedDeviceCount: number;
telemetryStatus: "ready" | "empty" | "unavailable";
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100;
export async function fetchScadaDeviceCollection(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<FeatureCollection<Point, ScadaDevice>> {
const accessToken = await getAccessToken?.();
const projects = projectPageSchema.parse(
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) {
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
}
return (await fetchScadaSnapshot(getAccessToken, signal)).collection;
}
export async function fetchScadaSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal,
now = new Date()
): Promise<ScadaSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const page = scadaDevicePageSchema.parse(
await requestJson(
@@ -69,14 +75,49 @@ export async function fetchScadaDeviceCollection(
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
}
const readings = await fetchLatestReadings(
page.items.map((device) => device.device_id),
accessToken,
project.project_id,
signal,
now
);
const latestByDevice = new Map<string, z.infer<typeof scadaReadingSchema>>();
readings.rows.forEach((reading) => {
if (reading.monitored_value === null) return;
const current = latestByDevice.get(reading.device_id);
if (!current || Date.parse(reading.time) >= Date.parse(current.time)) {
latestByDevice.set(reading.device_id, reading);
}
});
const observedAt = [...latestByDevice.values()].reduce<string | null>(
(latest, reading) => !latest || Date.parse(reading.time) > Date.parse(latest)
? reading.time
: latest,
null
);
return {
type: "FeatureCollection",
features: page.items.flatMap(toScadaFeature)
collection: {
type: "FeatureCollection",
features: page.items.flatMap((device) => toScadaFeature(device, latestByDevice.get(device.device_id)))
},
observedAt,
observedDeviceCount: latestByDevice.size,
telemetryStatus: readings.status === "unavailable"
? "unavailable"
: latestByDevice.size > 0
? "ready"
: "empty"
};
}
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
function toScadaFeature(
device: ScadaDevice,
reading?: z.infer<typeof scadaReadingSchema>
): Array<Feature<Point, ScadaDevice>> {
if (device.longitude === null || device.latitude === null) return [];
const latestValue = reading?.monitored_value;
return [{
type: "Feature",
id: device.device_id,
@@ -84,26 +125,73 @@ function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>>
type: "Point",
coordinates: [device.longitude, device.latitude]
},
properties: device
properties: {
...device,
...(reading && latestValue !== null
? {
latest_value: latestValue,
latest_value_with_unit: `${latestValue} ${device.measurement_unit}`.trim(),
latest_observed_at: reading.time,
latest_value_source: "monitored_value"
}
: {})
}
}];
}
async function fetchLatestReadings(
deviceIds: string[],
accessToken: string | null | undefined,
projectId: string,
signal: AbortSignal | undefined,
now: Date
): Promise<{
rows: Array<z.infer<typeof scadaReadingSchema>>;
status: "ready" | "unavailable";
}> {
if (deviceIds.length === 0) return { rows: [], status: "ready" };
const batches = chunk(deviceIds, SCADA_READING_BATCH_SIZE);
try {
const responses = await Promise.all(batches.map(async (batch) => {
const query = new URLSearchParams({
start_time: new Date(now.getTime() - SCADA_LOOKBACK_MS).toISOString(),
end_time: now.toISOString(),
device_ids: batch.join(",")
});
return scadaReadingsSchema.parse(await requestJson(
`/api/v1/timeseries/scada-readings?${query.toString()}`,
accessToken,
projectId,
signal
));
}));
return { rows: responses.flat(), status: "ready" };
} catch (error) {
if (signal?.aborted) throw error;
return { rows: [], status: "unavailable" };
}
}
function chunk<T>(values: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size));
}
return chunks;
}
async function requestJson(
path: string,
accessToken: string | null | undefined,
projectId: string | undefined,
signal: AbortSignal | undefined
) {
const headers = new Headers({ Accept: "application/json" });
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers.set("X-Project-Id", projectId);
const headers = createWorkbenchHeaders(accessToken, projectId);
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String(payload.detail)
: `HTTP ${response.status}`;
throw new Error(`SCADA API请求失败:${detail}`);
throw new Error(getServerError(payload, response.status, "SCADA API请求"));
}
return payload;
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWorkbenchAnalysisHistory } from "./workbench-analysis-history-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
describe("workbench analysis history client", () => {
afterEach(() => vi.unstubAllGlobals());
it("maps a bounded page and binds it to the selected project", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{
run_id: "run-1",
name: "北区传感器布点",
run_type: "sensor_placement",
run_type_label: "传感器布点",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:30:00Z",
started_at: "2026-09-17T02:20:00Z",
has_results: true
}],
limit: 10,
offset: 20,
next_offset: 30
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const page = await fetchWorkbenchAnalysisHistory({
limit: 10,
offset: 20,
getAccessToken: vi.fn().mockResolvedValue("access-token")
});
expect(page).toEqual({
items: [expect.objectContaining({
runId: "run-1",
runTypeLabel: "传感器布点",
hasResults: true
})],
nextOffset: 30
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"/api/v1/workbench/analysis-runs?limit=10&offset=20"
);
const headers = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer access-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("rejects an untyped history payload", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ run_id: "run-1", name: "missing fields" }],
limit: 10,
offset: 0,
next_offset: null
}), { status: 200 })));
await expect(fetchWorkbenchAnalysisHistory()).rejects.toThrow();
});
});
@@ -0,0 +1,97 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
status: z.string().trim().min(1),
status_label: z.string().trim().min(1),
created_by: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
started_at: z.string().datetime({ offset: true }),
has_results: z.boolean()
});
const analysisHistoryPageSchema = z.object({
items: z.array(analysisHistoryItemSchema),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable()
});
export type WorkbenchAnalysisHistoryItem = {
runId: string;
name: string;
runType: string;
runTypeLabel: string;
status: string;
statusLabel: string;
createdBy: string;
createdAt: string;
startedAt: string;
hasResults: boolean;
};
export type WorkbenchAnalysisHistoryPage = {
items: WorkbenchAnalysisHistoryItem[];
nextOffset: number | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory(
options: {
limit?: number;
offset?: number;
getAccessToken?: AccessTokenProvider;
signal?: AbortSignal;
} = {}
): Promise<WorkbenchAnalysisHistoryPage> {
const limit = options.limit ?? 10;
const offset = options.offset ?? 0;
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error("分析成果历史每页数量必须在 1 到 100 之间");
}
if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效");
}
const { project, accessToken } = await findOwnedWorkbenchProject(
options.getAccessToken,
options.signal
);
const search = new URLSearchParams({
limit: String(limit),
offset: String(offset)
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal: options.signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
}
const page = analysisHistoryPageSchema.parse(payload);
return {
items: page.items.map((item) => ({
runId: item.run_id,
name: item.name,
runType: item.run_type,
runTypeLabel: item.run_type_label,
status: item.status,
statusLabel: item.status_label,
createdBy: item.created_by,
createdAt: item.created_at,
startedAt: item.started_at,
hasResults: item.has_results
})),
nextOffset: page.next_offset
};
}
@@ -0,0 +1,405 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchAnalysisRun,
fetchWorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
const runtimeSnapshot = {
availability: "ready",
source: "scheduled_tasks",
scenario_availability: "ready",
artifact_availability: "ready",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "ready",
generated_at: "2026-09-17T02:30:00Z",
conditions: [{
id: "schedule-run-1",
task_id: "backend-schedule",
session_id: "scheduled-condition-schedule-run-1",
title: "演示 合成低压调查",
summary: "来自后端持久化 Action Run 的 演示 沙箱测试状态。",
status: "error",
risk_level: "critical",
scheduled_at: "2026-09-17T02:20:00Z",
updated_at: "2026-09-17T02:25:00Z",
detail: "演示 测试运行状态:已失败。",
recommendation: "请检查失败步骤。",
evidence: ["运行状态:failed"],
model_name: "TJWater scheduler"
}],
alerts: [{
id: "scheduled-condition-alert-schedule-run-1",
condition_id: "schedule-run-1",
source: {
task_type: "analysis_task",
task_id: "backend-schedule",
run_id: "schedule-run-1"
},
title: "演示 测试待复核:演示 合成低压调查",
description: "运行失败。",
occurred_at: "2026-09-17T02:25:00Z",
severity: "critical"
}],
scenarios: [{
id: "analysis-run-analysis-1",
run_id: "analysis-1",
name: "北区爆管定位",
description: "爆管定位 · 已完成 · 创建人 alice",
status: "review",
run_type: "burst_location",
run_type_label: "爆管定位",
run_status: "completed",
created_at: "2026-09-17T02:20:00Z"
}],
latest_analysis: {
run_id: "analysis-1",
name: "北区爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [{
result_type: "burst_location",
created_at: "2026-09-17T02:20:00Z",
facts: [{ label: "关联管段", value: "P-42" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "后端爆管定位" },
geometry: { type: "Point", coordinates: [121.5, 30.9] }
}]
}
},
work_orders: []
};
describe("workbench runtime API client", () => {
afterEach(() => vi.unstubAllGlobals());
it("resolves the project and maps a persisted scheduled condition", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(runtimeSnapshot), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot(
vi.fn().mockResolvedValue("access-token")
);
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.conditions).toEqual([
expect.objectContaining({
id: "schedule-run-1",
kind: "condition",
taskId: "backend-schedule",
status: "error",
modelName: "TJWater scheduler"
})
]);
expect(snapshot.alerts[0]).toEqual(expect.objectContaining({
conditionId: "schedule-run-1",
id: "scheduled-condition-alert-schedule-run-1",
title: "演示 测试待复核:演示 合成低压调查",
source: {
taskType: "analysis_task",
taskId: "backend-schedule",
runId: "schedule-run-1"
}
}));
expect(snapshot.scenarios[0]).toEqual(expect.objectContaining({
id: "analysis-run-analysis-1",
runId: "analysis-1",
runType: "burst_location"
}));
expect(snapshot.latestAnalysisArtifact).toEqual(expect.objectContaining({
id: "persisted-analysis-analysis-1",
title: "北区爆管定位",
confidence: "后端未提供",
mapRelation: "required",
spatialOverlay: expect.objectContaining({ type: "FeatureCollection" }),
sources: ["项目 BizDB · analysis.runs", "项目 BizDB · analysis.results"]
}));
expect(snapshot.latestAnalysisArtifact?.blocks).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "data-table",
rows: [expect.objectContaining({ facts: "关联管段:P-42" })]
})
]));
expect(snapshot.simulationOverlay?.features).toHaveLength(1);
const runtimeHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(runtimeHeaders.get("Authorization")).toBe("Bearer access-token");
expect(runtimeHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/workbench/runtime");
});
it("keeps legacy analysis conditions out of the operational task feed", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
source: "analysis_runs",
conditions: [{
id: "analysis-run-analysis-1",
task_id: "backend-analysis",
session_id: "scheduled-condition-analysis-run-analysis-1",
title: "北区爆管定位",
summary: "来自旧版后端的分析运行投影。",
status: "completed",
risk_level: "normal",
scheduled_at: "2026-09-17T02:10:00Z",
updated_at: "2026-09-17T02:20:00Z",
detail: "分析结果已完成。",
recommendation: null,
evidence: ["运行 ID:analysis-1"],
model_name: "TJWater analysis runtime"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.source).toBe("none");
expect(snapshot.conditions).toEqual([]);
expect(snapshot.latestAnalysisArtifact).toEqual(expect.objectContaining({
id: "persisted-analysis-analysis-1",
title: "北区爆管定位"
}));
expect(snapshot.scenarios).toHaveLength(1);
});
it("rejects an untyped runtime payload instead of treating it as real data", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...runtimeSnapshot, source: "production" }), {
status: 200
}));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchRuntimeSnapshot()).rejects.toThrow();
});
it("loads the requested analysis run instead of reusing the latest result", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
const selectedAnalysis = {
...runtimeSnapshot.latest_analysis,
run_id: runId,
name: "历史爆管定位",
results: [{
result_type: "burst_location",
created_at: "2026-09-17T01:20:00Z",
facts: [{ label: "关联管段", value: "P-older" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(selectedAnalysis), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const projection = await fetchWorkbenchAnalysisRun(
runId,
vi.fn().mockResolvedValue("access-token")
);
expect(projection.runId).toBe(runId);
expect(projection.artifact).toEqual(expect.objectContaining({
id: `persisted-analysis-${runId}`,
title: "历史爆管定位"
}));
expect(projection.artifact.blocks).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "data-table",
rows: [expect.objectContaining({ facts: "关联管段:P-older" })]
})
]));
expect(projection.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
`/api/v1/workbench/analysis-runs/${runId}`
);
const headers = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer access-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("drops invalid backend map geometry without discarding other real sections", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
latest_analysis: {
...runtimeSnapshot.latest_analysis,
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: ["east", "north"] }
}]
}
}
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.scenarios).toHaveLength(1);
expect(snapshot.simulationOverlay).toBeNull();
expect(snapshot.latestAnalysisArtifact?.mapRelation).toBe("none");
expect(snapshot.latestAnalysisArtifact).not.toHaveProperty("spatialOverlay");
});
it("maps provisioned backend work orders without using the demo shape", async () => {
const backendWorkOrder = {
id: "wo-1",
code: "WO-20260917-001",
scheduled_at: "2026-09-17T03:00:00Z",
title: "北区阀门现场复核",
summary: "复核阀门开度并回传。",
status: "completed",
risk_level: "attention",
updated_at: "2026-09-17T02:30:00Z",
detail: "来自正式工单同步适配器。",
recommendation: "完成后复核压力。",
duration_minutes: 45,
source: "生产工单系统",
dispatcher: "调度中心",
priority: "urgent",
subtasks: [{
id: "wo-1-subtask-1",
title: "阀门开度复核",
summary: "现场复核阀门开度。",
status: "completed",
scheduled_at: "2026-09-17T03:00:00Z",
location: "北区阀门 V-1",
assignee: "北区班组",
duration_minutes: 45,
reply_window_minutes: 20,
stages: [
{
id: "dispatch",
sequence: 1,
title: "派发",
status: "done",
owner: "调度中心",
time_label: "10:50",
description: "已派发。"
},
{
id: "execution",
sequence: 2,
title: "执行中",
status: "done",
owner: "北区班组",
time_label: "11:00—11:45",
description: "现场执行中。"
},
{
id: "reply",
sequence: 3,
title: "待复令",
status: "done",
owner: "北区班组",
time_label: "11:45—12:05",
description: "等待复令。"
}
],
reply_requirements: ["回传阀门开度"]
}]
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
work_order_availability: "ready",
work_orders: [backendWorkOrder]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.workOrderAvailability).toBe("ready");
expect(snapshot.workOrders).toEqual([expect.objectContaining({
id: "wo-1",
kind: "work_order",
code: "WO-20260917-001",
status: "completed",
source: "生产工单系统",
subtasks: expect.arrayContaining([
expect.objectContaining({
id: "wo-1-subtask-1",
stages: expect.arrayContaining([
expect.objectContaining({ id: "execution", sequence: 2, timeLabel: "11:00—11:45" })
])
})
])
})]);
});
it("maps a provisioned backend alert without inventing a condition link", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
alert_availability: "ready",
alerts: [{
id: "backend-alert-alarm-1",
condition_id: null,
source: {
task_type: "scheduled_task",
task_id: "scada-diagnosis-5m",
run_id: "trigger-1"
},
title: "泵站通信告警",
description: "由 SCADA 数据诊断任务明确推送。",
occurred_at: "2026-09-17T02:29:00Z",
severity: "warning"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.alertAvailability).toBe("ready");
expect(snapshot.alerts).toEqual([expect.objectContaining({
id: "backend-alert-alarm-1",
title: "泵站通信告警",
severity: "warning",
source: {
taskType: "scheduled_task",
taskId: "scada-diagnosis-5m",
runId: "trigger-1"
}
})]);
expect(snapshot.alerts[0]).not.toHaveProperty("conditionId");
});
});
@@ -0,0 +1,573 @@
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type {
ScheduledConditionRecord,
ScheduledWorkOrderItem,
WorkbenchAlert,
WorkbenchScenario
} from "../types";
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
// Keep legacy analysis values parseable while old servers are rolling forward;
// they are filtered before the operational workbench model is created.
const runtimeConditionSchema = z.object({
id: z.string().trim().min(1),
task_id: z.enum(["backend-analysis", "backend-schedule"]),
session_id: z.string().trim().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "completed", "warning", "error"]),
risk_level: z.enum(["normal", "attention", "critical"]),
scheduled_at: z.string().datetime({ offset: true }),
updated_at: z.string().datetime({ offset: true }),
detail: z.string().trim().min(1),
recommendation: z.string().trim().min(1).nullable(),
evidence: z.array(z.string().trim().min(1)),
model_name: z.enum(["TJWater analysis runtime", "TJWater scheduler"])
});
type RuntimeConditionPayload = z.infer<typeof runtimeConditionSchema>;
type OperationalRuntimeConditionPayload = RuntimeConditionPayload & {
task_id: "backend-schedule";
};
const runtimeAlertSchema = z.object({
id: z.string().trim().min(1),
condition_id: z.string().trim().min(1).nullable(),
source: z.object({
task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]),
task_id: z.string().trim().min(1),
run_id: z.string().trim().min(1)
}),
title: z.string().trim().min(1),
description: z.string().trim().min(1),
occurred_at: z.string().datetime({ offset: true }),
severity: z.enum(["warning", "critical"])
});
const runtimeScenarioSchema = z.object({
id: z.string().trim().min(1),
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
description: z.string().trim().min(1),
status: z.enum(["draft", "active", "review"]),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
run_status: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true })
});
const runtimeAnalysisResultSchema = z.object({
result_type: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
facts: z.array(z.object({
label: z.string().trim().min(1),
value: z.string().trim().min(1)
}))
});
const geoJsonFeatureCollectionSchema = z.custom<FeatureCollection<Geometry>>(
isGeoJsonFeatureCollection
);
const runtimeLatestAnalysisSchema = z.object({
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
status: z.string().trim().min(1),
status_label: z.string().trim().min(1),
created_by: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
started_at: z.string().datetime({ offset: true }),
results: z.array(runtimeAnalysisResultSchema),
map_payload: z.unknown().nullable()
});
const runtimeWorkOrderStageSchema = z.object({
id: z.string().trim().min(1),
sequence: z.number().int().positive(),
title: z.string().trim().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]),
owner: z.string().trim().min(1),
time_label: z.string().trim().min(1),
description: z.string().trim().min(1)
});
const runtimeWorkOrderSubtaskSchema = z.object({
id: z.string().trim().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]),
scheduled_at: z.string().datetime({ offset: true }),
location: z.string().trim().min(1),
assignee: z.string().trim().min(1),
duration_minutes: z.number().int().positive(),
reply_window_minutes: z.number().int().positive(),
stages: z.array(runtimeWorkOrderStageSchema).min(1),
reply_requirements: z.array(z.string().trim().min(1))
}).superRefine((subtask, context) => {
if (subtask.stages.some((stage, index) => stage.sequence !== index + 1)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段序号必须从 1 连续递增",
path: ["stages"]
});
}
if (new Set(subtask.stages.map((stage) => stage.id)).size !== subtask.stages.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段编号不能重复",
path: ["stages"]
});
}
if (subtask.stages.filter((stage) => stage.status === "current").length > 1) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "单个子任务最多只能有一个当前阶段",
path: ["stages"]
});
}
});
const runtimeWorkOrderSchema = z.object({
id: z.string().trim().min(1),
code: z.string().trim().min(1),
scheduled_at: z.string().datetime({ offset: true }),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "awaiting_reply", "completed"]),
risk_level: z.enum(["normal", "attention", "critical"]),
updated_at: z.string().datetime({ offset: true }),
detail: z.string().trim().min(1),
recommendation: z.string().trim().min(1).nullable(),
duration_minutes: z.number().int().positive(),
source: z.string().trim().min(1),
dispatcher: z.string().trim().min(1),
priority: z.enum(["normal", "urgent"]),
subtasks: z.array(runtimeWorkOrderSubtaskSchema).min(1)
}).superRefine((workOrder, context) => {
if (new Set(workOrder.subtasks.map((subtask) => subtask.id)).size !== workOrder.subtasks.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "工单子任务编号不能重复",
path: ["subtasks"]
});
}
});
const runtimeSnapshotSchema = z.object({
availability: z.enum(["ready", "not_configured", "degraded"]),
source: z.enum(["none", "analysis_runs", "scheduled_tasks"]),
scenario_availability: z.enum(["ready", "not_configured", "degraded"]),
artifact_availability: z.enum(["ready", "not_configured", "degraded"]),
schedule_availability: z.enum(["ready", "not_configured", "degraded"]),
work_order_availability: z.enum(["ready", "not_configured", "degraded"]),
alert_availability: z.enum(["ready", "not_configured", "degraded"]),
generated_at: z.string().datetime({ offset: true }),
conditions: z.array(runtimeConditionSchema),
alerts: z.array(runtimeAlertSchema),
scenarios: z.array(runtimeScenarioSchema),
latest_analysis: runtimeLatestAnalysisSchema.nullable(),
work_orders: z.array(runtimeWorkOrderSchema)
});
type ProjectionAvailability = "ready" | "not_configured" | "degraded";
export type WorkbenchRuntimeSnapshot = {
availability: ProjectionAvailability;
source: "none" | "scheduled_tasks";
scenarioAvailability: ProjectionAvailability;
artifactAvailability: ProjectionAvailability;
scheduleAvailability: ProjectionAvailability;
workOrderAvailability: ProjectionAvailability;
alertAvailability: ProjectionAvailability;
generatedAt: string;
conditions: ScheduledConditionRecord[];
alerts: WorkbenchAlert[];
scenarios: WorkbenchScenario[];
latestAnalysisArtifact: AnalysisArtifact | null;
simulationOverlay: FeatureCollection<Geometry> | null;
workOrders: ScheduledWorkOrderItem[];
};
export type WorkbenchAnalysisProjection = {
runId: string;
artifact: AnalysisArtifact;
simulationOverlay: FeatureCollection<Geometry> | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchRuntimeSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchRuntimeSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/runtime`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "工作台运行态请求"));
}
const snapshot = runtimeSnapshotSchema.parse(payload);
const latestAnalysis = snapshot.latest_analysis
? projectWorkbenchAnalysis(snapshot.latest_analysis)
: null;
const operationalConditions = snapshot.conditions.filter(isOperationalRuntimeCondition);
return {
availability: snapshot.availability,
source: resolveOperationalRuntimeSource(snapshot, operationalConditions),
scenarioAvailability: snapshot.scenario_availability,
artifactAvailability: snapshot.artifact_availability,
scheduleAvailability: snapshot.schedule_availability,
workOrderAvailability: snapshot.work_order_availability,
alertAvailability: snapshot.alert_availability,
generatedAt: snapshot.generated_at,
conditions: operationalConditions.map((condition) => ({
id: condition.id,
kind: "condition",
taskId: condition.task_id,
dataSource: getConditionDataSource(condition.task_id),
sessionId: condition.session_id,
title: condition.title,
summary: condition.summary,
status: condition.status,
riskLevel: condition.risk_level,
scheduledAt: condition.scheduled_at,
updatedAt: Date.parse(condition.updated_at),
detail: condition.detail,
recommendation: condition.recommendation ?? undefined,
evidence: condition.evidence,
modelName: condition.model_name
})),
alerts: snapshot.alerts.map((alert) => ({
...(alert.condition_id ? { conditionId: alert.condition_id } : {}),
id: alert.id,
title: alert.title,
description: alert.description,
source: {
taskType: alert.source.task_type,
taskId: alert.source.task_id,
runId: alert.source.run_id
},
severity: alert.severity,
occurredAt: alert.occurred_at,
time: formatRuntimeAlertTime(alert.occurred_at)
})),
scenarios: snapshot.scenarios.map((scenario) => ({
id: scenario.id,
name: scenario.name,
description: scenario.description,
status: scenario.status,
runId: scenario.run_id,
runType: scenario.run_type,
runStatus: scenario.run_status,
createdAt: scenario.created_at
})),
latestAnalysisArtifact: latestAnalysis?.artifact ?? null,
simulationOverlay: latestAnalysis?.simulationOverlay ?? null,
workOrders: snapshot.work_orders.map((workOrder) => ({
id: workOrder.id,
kind: "work_order",
code: workOrder.code,
scheduledAt: workOrder.scheduled_at,
title: workOrder.title,
summary: workOrder.summary,
status: workOrder.status,
riskLevel: workOrder.risk_level,
updatedAt: Date.parse(workOrder.updated_at),
detail: workOrder.detail,
recommendation: workOrder.recommendation ?? undefined,
durationMinutes: workOrder.duration_minutes,
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
subtasks: workOrder.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({
id: stage.id,
sequence: stage.sequence,
title: stage.title,
status: stage.status,
owner: stage.owner,
timeLabel: stage.time_label,
description: stage.description
})),
replyRequirements: subtask.reply_requirements
}))
}))
};
}
export async function fetchWorkbenchAnalysisRun(
runId: string,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
}
const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致");
}
return projectWorkbenchAnalysis(analysis);
}
function projectWorkbenchAnalysis(
analysis: z.infer<typeof runtimeLatestAnalysisSchema>
): WorkbenchAnalysisProjection {
const parsedSimulationOverlay = analysis.map_payload
? geoJsonFeatureCollectionSchema.safeParse(analysis.map_payload)
: null;
const simulationOverlay = parsedSimulationOverlay?.success
? parsedSimulationOverlay.data
: null;
return {
runId: analysis.run_id,
artifact: createPersistedAnalysisArtifact(analysis, simulationOverlay),
simulationOverlay
};
}
function createPersistedAnalysisArtifact(
analysis: z.infer<typeof runtimeLatestAnalysisSchema>,
simulationOverlay: FeatureCollection<Geometry> | null
): AnalysisArtifact {
const resultTypes = [...new Set(analysis.results.map((result) => result.result_type))];
const completed = ["completed", "succeeded"].includes(analysis.status);
const lifecycle: ArtifactLifecycle = completed ? "reviewed" : "draft";
const artifactType = getArtifactType(analysis.run_type);
const factCount = analysis.results.reduce((total, result) => total + result.facts.length, 0);
const resultSummary = analysis.results.length > 0
? `后端记录状态为${analysis.status_label},已持久化 ${analysis.results.length} 条分析结果。`
: `后端记录状态为${analysis.status_label},当前未查询到独立结果记录。`;
return {
id: `persisted-analysis-${analysis.run_id}`,
origin: "backend",
type: artifactType,
lifecycle,
revision: 1,
title: analysis.name,
subtitle: `${analysis.run_type_label} · 项目后端分析运行`,
summary: resultSummary,
scope: `运行 ${analysis.run_id}`,
confidence: "后端未提供",
generatedAt: formatRuntimeDateTime(analysis.created_at),
analyticalTimeRange: {
start: analysis.started_at,
end: analysis.created_at
},
preferredView: simulationOverlay ? "map_split" : "focus",
mapRelation: simulationOverlay ? "required" : "none",
...(simulationOverlay ? { spatialOverlay: simulationOverlay } : {}),
blocks: [
{
id: `persisted-analysis-${analysis.run_id}-metrics`,
kind: "metric-grid",
title: "运行摘要",
span: 12,
metrics: [
{
label: "后端状态",
value: analysis.status_label,
detail: analysis.status,
tone: getStatusTone(analysis.status)
},
{
label: "结果记录",
value: String(analysis.results.length),
detail: "analysis.results",
tone: analysis.results.length > 0 ? "success" : "neutral"
},
{
label: "结果类型",
value: String(resultTypes.length),
detail: resultTypes.join("、") || "暂无",
tone: "info"
},
{
label: "成果事实",
value: String(factCount),
detail: factCount > 0 ? "后端已识别字段" : "暂无已识别字段",
tone: factCount > 0 ? "success" : "neutral"
},
{
label: "创建人",
value: analysis.created_by,
detail: analysis.run_type_label,
tone: "neutral"
}
]
},
{
id: `persisted-analysis-${analysis.run_id}-metadata`,
kind: "narrative",
title: "后端运行记录",
span: 12,
paragraphs: [
`运行 ID:${analysis.run_id}`,
`分析类型:${analysis.run_type_label}(${analysis.run_type})`,
`创建人:${analysis.created_by};状态:${analysis.status_label}。`
]
},
...(analysis.results.length > 0
? [{
id: `persisted-analysis-${analysis.run_id}-results`,
kind: "data-table" as const,
title: "持久化结果索引",
description: "来自项目 BizDB analysis.results 的结果记录",
span: 12 as const,
columns: [
{ key: "type", label: "结果类型" },
{ key: "createdAt", label: "写入时间" },
{ key: "facts", label: "后端成果事实" }
],
rows: analysis.results.map((result) => ({
type: result.result_type,
createdAt: formatRuntimeDateTime(result.created_at),
facts: result.facts.length > 0
? result.facts.map((fact) => `${fact.label}:${fact.value}`).join(";")
: "未提供已识别字段"
}))
}]
: [])
],
sources: [
"项目 BizDB · analysis.runs",
...(analysis.results.length > 0 ? ["项目 BizDB · analysis.results"] : [])
],
limitations: [
factCount > 0
? "当前投影展示后端持久化元数据、结果索引和已识别的成果字段;未透传任意结果载荷。"
: "当前投影只展示后端持久化元数据与结果索引。",
"后端记录没有提供综合置信度。"
],
actions: []
};
}
function getArtifactType(runType: string): ArtifactType {
if (["burst_detection", "burst_location", "dma_leak_identification"].includes(runType)) {
return "diagnosis";
}
if (runType === "sensor_placement") return "metric_analysis";
return "simulation";
}
function getStatusTone(status: string) {
if (["completed", "succeeded"].includes(status)) return "success" as const;
if (["failed", "error"].includes(status)) return "danger" as const;
if (status === "running") return "info" as const;
return "warning" as const;
}
function formatRuntimeAlertTime(value: string) {
const date = new Date(value);
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function getConditionDataSource(
_taskId: OperationalRuntimeConditionPayload["task_id"]
) {
return "scheduled_tasks" as const;
}
function isOperationalRuntimeCondition(
condition: RuntimeConditionPayload
): condition is OperationalRuntimeConditionPayload {
return condition.task_id === "backend-schedule";
}
function resolveOperationalRuntimeSource(
snapshot: z.infer<typeof runtimeSnapshotSchema>,
conditions: OperationalRuntimeConditionPayload[]
): WorkbenchRuntimeSnapshot["source"] {
if (snapshot.source === "scheduled_tasks") return "scheduled_tasks";
return conditions.length > 0 ? "scheduled_tasks" : "none";
}
function formatRuntimeDateTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function isGeoJsonFeatureCollection(value: unknown): value is FeatureCollection<Geometry> {
if (!isRecord(value) || value.type !== "FeatureCollection" || !Array.isArray(value.features)) {
return false;
}
return value.features.every((feature) =>
isRecord(feature)
&& feature.type === "Feature"
&& (feature.properties === null || isRecord(feature.properties))
&& (feature.geometry === null || isGeoJsonGeometry(feature.geometry))
);
}
function isGeoJsonGeometry(value: unknown): value is Geometry {
if (!isRecord(value) || typeof value.type !== "string") return false;
if (value.type === "GeometryCollection") {
return Array.isArray(value.geometries) && value.geometries.every(isGeoJsonGeometry);
}
const coordinates = value.coordinates;
if (value.type === "Point") return isPosition(coordinates);
if (value.type === "MultiPoint" || value.type === "LineString") {
return isPositionArray(coordinates);
}
if (value.type === "MultiLineString" || value.type === "Polygon") {
return Array.isArray(coordinates) && coordinates.every(isPositionArray);
}
if (value.type === "MultiPolygon") {
return Array.isArray(coordinates)
&& coordinates.every((polygon) =>
Array.isArray(polygon) && polygon.every(isPositionArray)
);
}
return false;
}
function isPosition(value: unknown): value is Position {
return Array.isArray(value)
&& value.length >= 2
&& value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
}
function isPositionArray(value: unknown): value is Position[] {
return Array.isArray(value) && value.every(isPosition);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -3,7 +3,8 @@ import {
getRunningWorkflowState
} from "../data/running-workflows";
import type {
ScheduledConditionRecord
ScheduledConditionRecord,
WorkbenchRuntimeDataSource
} from "../types";
export type ConditionStepTickerItem = {
@@ -14,8 +15,7 @@ export type ConditionStepTickerItem = {
export type ConditionStepTickerView = {
title: string;
progressLabel: string;
progressValue: number;
progressValue?: number;
currentStep: ConditionStepTickerItem | null;
};
@@ -41,7 +41,8 @@ export function selectTickerCondition(
export function createVisibleTickerCards(
runningConditions: ScheduledConditionRecord[],
rotationIndex: number,
nowMs: number
nowMs: number,
dataSource: WorkbenchRuntimeDataSource = "mock"
): TickerStackCard[] {
if (runningConditions.length === 0) {
return [];
@@ -55,7 +56,7 @@ export function createVisibleTickerCards(
return {
condition,
view: createConditionStepView(condition, nowMs, runningConditions.length),
view: createConditionStepView(condition, nowMs, runningConditions.length, dataSource),
stackIndex
};
});
@@ -64,8 +65,23 @@ export function createVisibleTickerCards(
function createConditionStepView(
condition: ScheduledConditionRecord,
nowMs: number,
runningConditionCount: number
runningConditionCount: number,
dataSource: WorkbenchRuntimeDataSource
): ConditionStepTickerView {
const effectiveDataSource = condition.dataSource ?? dataSource;
if (effectiveDataSource !== "mock") {
return {
title: runningConditionCount > 1
? `${condition.title} · 后端执行中 · ${runningConditionCount} 个执行中`
: `${condition.title} · 后端执行中`,
currentStep: {
id: "backend-status",
title: "后端状态",
detail: condition.detail ?? condition.summary
}
};
}
const workflow = getRunningWorkflowDefinition(condition);
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
const currentStep = workflow.steps[workflowState.currentStepIndex];
@@ -79,9 +95,8 @@ function createConditionStepView(
return {
title: runningConditionCount > 1
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
: `${workflow.title} · ${condition.title}`,
progressLabel: `${workflowState.progress}%`,
? `演示 · ${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
: `演示 · ${workflow.title} · ${condition.title}`,
progressValue: workflowState.progress,
currentStep: currentStepItem
};
@@ -77,4 +77,44 @@ describe("createVisibleTickerCards", () => {
]);
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
});
it("uses persisted backend status without inferring workflow or progress", () => {
const condition: ScheduledConditionRecord = {
...createRunningCondition("backend-condition", "scada-diagnosis"),
taskId: "backend-schedule",
dataSource: "scheduled_tasks",
title: "演示 后端调查",
summary: "后端任务仍在运行",
detail: "后端记录:等待计算结果"
};
const [card] = createVisibleTickerCards(
[condition],
0,
Date.parse("2026-07-08T10:09:00+08:00"),
"scheduled_tasks"
);
expect(card?.view).toEqual({
title: "演示 后端调查 · 后端执行中",
currentStep: {
id: "backend-status",
title: "后端状态",
detail: "后端记录:等待计算结果"
}
});
expect(card?.view.progressValue).toBeUndefined();
});
it("labels inferred mock workflow progress as demonstration data", () => {
const [card] = createVisibleTickerCards(
[createRunningCondition("condition-a", "scada-diagnosis")],
0,
Date.parse("2026-07-08T10:01:00+08:00"),
"mock"
);
expect(card?.view.title).toContain("演示 · SCADA 诊断流程");
expect(card?.view.progressValue).toBe(10);
});
});
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/shared/ui/cn";
import type {
ScheduledConditionRecord
ScheduledConditionRecord,
WorkbenchRuntimeDataSource
} from "../types";
import {
createVisibleTickerCards,
@@ -11,6 +12,7 @@ import {
type AgentTaskTickerProps = {
conditions: ScheduledConditionRecord[];
dataSource?: WorkbenchRuntimeDataSource;
className?: string;
};
@@ -86,6 +88,7 @@ const tickerIndicatorVariants: Variants = {
export function AgentTaskTicker({
conditions,
dataSource = "mock",
className
}: AgentTaskTickerProps) {
const sectionRef = useRef<HTMLElement | null>(null);
@@ -105,8 +108,8 @@ export function AgentTaskTicker({
const [visibilityRevision, setVisibilityRevision] = useState(0);
const runningConditions = conditions;
const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
[runningConditions, rotationIndex, nowMs]
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs, dataSource),
[dataSource, runningConditions, rotationIndex, nowMs]
);
const hasMultipleRunningTasks = runningConditions.length > 1;
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
@@ -391,7 +394,9 @@ function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
{view.title}
</span>
<TickerProgressBar progressLabel={view.progressLabel} muted />
{view.progressValue !== undefined ? (
<TickerProgressBar progressValue={view.progressValue} muted />
) : null}
</div>
</div>
);
@@ -417,17 +422,19 @@ function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
</span>
</div>
</div>
<TickerProgressBar progressLabel={view.progressLabel} />
{view.progressValue !== undefined ? (
<TickerProgressBar progressValue={view.progressValue} />
) : null}
</div>
</div>
);
}
function TickerProgressBar({
progressLabel,
progressValue,
muted = false
}: {
progressLabel: string;
progressValue: number;
muted?: boolean;
}) {
return (
@@ -443,7 +450,7 @@ function TickerProgressBar({
"status-tone-info block h-full rounded-full",
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
)}
animate={{ width: progressLabel }}
animate={{ width: `${Math.min(100, Math.max(0, progressValue))}%` }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { fetchWorkbenchAnalysisRun } from "../api/workbench-runtime-client";
import {
fetchWorkbenchAnalysisHistory,
type WorkbenchAnalysisHistoryItem
} from "../api/workbench-analysis-history-client";
import type { AnalysisArtifact } from "../workspace/workspace-model";
import {
WorkbenchEmptyState,
WorkbenchNotice,
WorkbenchSection
} from "./workbench-panel-ui";
const HISTORY_PAGE_SIZE = 10;
export function AnalysisHistorySection({
getAccessToken,
onOpenArtifact
}: {
getAccessToken?: AccessTokenProvider;
onOpenArtifact: (artifact: AnalysisArtifact) => void;
}) {
const [items, setItems] = useState<WorkbenchAnalysisHistoryItem[]>([]);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [openingRunId, setOpeningRunId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadedRef = useRef(false);
const listControllerRef = useRef<AbortController | null>(null);
const artifactControllerRef = useRef<AbortController | null>(null);
useEffect(() => () => {
listControllerRef.current?.abort();
artifactControllerRef.current?.abort();
}, []);
const load = useCallback(async (more = false) => {
if (loading) return;
const offset = more ? nextOffset : 0;
if (more && offset === null) return;
listControllerRef.current?.abort();
const controller = new AbortController();
listControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const page = await fetchWorkbenchAnalysisHistory({
limit: HISTORY_PAGE_SIZE,
offset: offset ?? 0,
getAccessToken,
signal: controller.signal
});
if (controller.signal.aborted) return;
setItems((current) => more
? [...current, ...page.items.filter(
(item) => !current.some((existing) => existing.runId === item.runId)
)]
: page.items);
setNextOffset(page.nextOffset);
loadedRef.current = true;
} catch (nextError) {
if (!controller.signal.aborted) {
setError(errorMessage(nextError));
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, [getAccessToken, loading, nextOffset]);
const openArtifact = async (item: WorkbenchAnalysisHistoryItem) => {
if (openingRunId) return;
artifactControllerRef.current?.abort();
const controller = new AbortController();
artifactControllerRef.current = controller;
setOpeningRunId(item.runId);
setError(null);
try {
const projection = await fetchWorkbenchAnalysisRun(
item.runId,
getAccessToken,
controller.signal
);
if (!controller.signal.aborted) onOpenArtifact(projection.artifact);
} catch (nextError) {
if (!controller.signal.aborted) setError(errorMessage(nextError));
} finally {
if (!controller.signal.aborted) setOpeningRunId(null);
}
};
return (
<WorkbenchSection
className="mb-3"
title="项目分析成果"
description="按需读取 BizDB 历史;打开成果不会重新执行分析。"
icon={<Archive size={15} />}
tone="info"
meta={loadedRef.current ? (
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-medium tabular-nums text-slate-600">
已加载 {items.length} 项
</span>
) : null}
>
<Disclosure
label="浏览项目分析成果"
onToggle={(event) => {
if (event.currentTarget.open && !loadedRef.current && !loading) void load();
}}
>
<div className="mt-1 border-t border-slate-200 pt-3">
{error ? <WorkbenchNotice role="alert" tone="danger" className="mb-3">{error}</WorkbenchNotice> : null}
{loading && !items.length ? (
<div role="status" className="flex min-h-20 items-center justify-center gap-2 text-xs text-slate-500">
<LoaderCircle size={15} className="animate-spin" />正在读取分析成果历史
</div>
) : null}
{!loading && loadedRef.current && !items.length ? (
<WorkbenchEmptyState
icon={<BarChart3 size={15} />}
title="暂无持久化分析成果"
description="新的分析运行会在保存到当前项目 BizDB 后出现在这里。"
/>
) : null}
{items.length ? (
<ol className="space-y-2" aria-label="项目分析成果列表">
{items.map((item) => (
<li key={item.runId}>
<button
type="button"
aria-label={`打开分析成果:${item.name}`}
disabled={Boolean(openingRunId)}
onClick={() => void openArtifact(item)}
className="group flex min-h-[68px] w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left shadow-xs transition-[border-color,background-color,scale] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] [@media(hover:hover)]:hover:border-blue-200 [@media(hover:hover)]:hover:bg-blue-50/55 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-wait disabled:opacity-60"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
{openingRunId === item.runId
? <LoaderCircle size={15} className="animate-spin" />
: <BarChart3 size={15} />}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-semibold text-slate-900">{item.name}</span>
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
</span>
<span className="mt-1 block truncate text-[11px] leading-4 text-slate-500">
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
</span>
<span className="mt-0.5 block text-[11px] leading-4 text-slate-400">
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
</span>
</span>
<ChevronRight size={14} className="shrink-0 text-slate-400 transition-colors group-hover:text-blue-600" />
</button>
</li>
))}
</ol>
) : null}
<div className="mt-3 flex justify-end gap-2">
{error && !items.length ? (
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
<RefreshCw size={14} />重新读取
</Button>
) : null}
{nextOffset !== null ? (
<Button type="button" size="sm" variant="ghost" disabled={loading || Boolean(openingRunId)} onClick={() => void load(true)}>
{loading ? <LoaderCircle size={14} className="animate-spin" /> : null}
加载更多成果
</Button>
) : null}
</div>
</div>
</Disclosure>
</WorkbenchSection>
);
}
function statusTone(status: string): StatusTone {
if (["completed", "succeeded"].includes(status)) return "success";
if (["failed", "error"].includes(status)) return "danger";
if (["running"].includes(status)) return "info";
if (["queued", "pending"].includes(status)) return "warning";
return "neutral";
}
function formatHistoryTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "分析成果历史请求失败";
}
@@ -0,0 +1,42 @@
import { BarChart3, X } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { Button } from "@/shared/ui/button";
import type { AnalysisArtifact } from "../workspace/workspace-model";
import { AnalysisHistorySection } from "./analysis-history-section";
export function AnalysisResultsPanel({
open,
onClose,
getAccessToken,
onOpenArtifact
}: {
open: boolean;
onClose: () => void;
getAccessToken?: AccessTokenProvider;
onOpenArtifact: (artifact: AnalysisArtifact) => void;
}) {
if (!open) return null;
return (
<aside
aria-label="项目分析成果"
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[60] flex w-[min(560px,calc(100%-1.5rem))] flex-col overflow-hidden rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)]"
>
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-blue-600 text-white">
<BarChart3 size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">项目分析成果</h2>
<p className="truncate text-xs text-slate-500">查看已写入当前项目 BizDB 的分析结果</p>
</div>
<Button size="icon" variant="ghost" aria-label="关闭分析成果面板" onClick={onClose}>
<X size={17} />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<AnalysisHistorySection getAccessToken={getAccessToken} onOpenArtifact={onOpenArtifact} />
</div>
</aside>
);
}
@@ -0,0 +1,90 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeaturePopover } from "./feature-popover";
const mocks = vi.hoisted(() => ({
fetchElementGisProperties: vi.fn()
}));
vi.mock("../api/gis-properties-client", async (importOriginal) => ({
...await importOriginal<typeof import("../api/gis-properties-client")>(),
fetchElementGisProperties: mocks.fetchElementGisProperties
}));
describe("feature GIS properties", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("keeps imported GIS fields folded by dataset", async () => {
mocks.fetchElementGisProperties.mockResolvedValue([{
dataset_key: "fx_pipe_inventory_20200811",
dataset_title: "奉贤 DN100 及以上管网台账",
source_date: "2020-08-11",
layer_key: "fx_20200811_dn100_pipes",
layer_name: "DN100 及以上管线台账",
source_fid: 117,
match_method: "geometry",
match_distance_m: 0.12,
attributes: { PIPE_D: 300, MATERIAL: "DI" }
}]);
render(
<FeaturePopover
feature={{
id: "DEMO-P001",
layer: "pipes",
title: "管线 DEMO-P001",
subtitle: "",
properties: {
id: "DEMO-P001",
diameter: 300,
status: "open"
}
}}
projectId="project-117"
getAccessToken={async () => "read-token"}
onClose={vi.fn()}
/>
);
expect(await screen.findByText("1 份")).toBeVisible();
expect(screen.getByText("PIPE_D")).not.toBeVisible();
fireEvent.click(screen.getByText("GIS 台账属性"));
expect(screen.getByText("DN100 及以上管线台账")).toBeVisible();
expect(screen.getByText("PIPE_D")).not.toBeVisible();
fireEvent.click(screen.getByText("DN100 及以上管线台账"));
expect(screen.getByTitle("300 mm")).toBeVisible();
expect(screen.getByText("材质")).toBeVisible();
expect(screen.getByTitle("球墨铸铁")).toBeVisible();
expect(screen.getByText("PIPE_D")).not.toBeVisible();
fireEvent.click(screen.getByText("全部原始字段 · 2 项"));
expect(screen.getByText("PIPE_D")).toBeVisible();
});
it("hides the GIS inventory section when the element has no linked records", async () => {
mocks.fetchElementGisProperties.mockResolvedValue([]);
render(
<FeaturePopover
feature={{
id: "P-NO-GIS",
layer: "pipes",
title: "未关联管线",
subtitle: "",
properties: { id: "P-NO-GIS", status: "open" }
}}
projectId="project-117"
getAccessToken={async () => "read-token"}
onClose={vi.fn()}
/>
);
await waitFor(() => expect(mocks.fetchElementGisProperties).toHaveBeenCalledOnce());
expect(screen.queryByText("GIS 台账属性")).not.toBeInTheDocument();
});
});
@@ -1,12 +1,14 @@
import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
showMapNotice
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
StatusDot,
@@ -14,10 +16,12 @@ import {
} from "@/shared/ui/status";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import { useElementGisProperties } from "../hooks/use-element-gis-properties";
import {
getFeaturePanelModel,
type FeaturePanelBadgeTone
} from "../utils/feature-properties";
import { presentGisAttributeGroup } from "../utils/gis-attribute-presentation";
import {
PipeFeatureIcon,
JunctionFeatureIcon,
@@ -31,10 +35,12 @@ import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = {
feature: DetailFeature | null;
projectId: string | null;
getAccessToken?: AccessTokenProvider;
onClose: () => void;
};
const FEATURE_LAYER_META: Record<
const FEATURE_LAYER_META: Partial<Record<
WaterNetworkSourceId,
{
icon: ComponentType<SupplyFeatureIconProps>;
@@ -43,14 +49,23 @@ const FEATURE_LAYER_META: Record<
labelClassName: string;
iconClassName: string;
}
> = {
>> = {
pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" },
reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" },
scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" },
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
};
const SOURCE_GIS_LAYER_META = {
icon: JunctionFeatureIcon,
label: "GIS 交付要素",
headerClassName: "bg-slate-50/80",
labelClassName: "text-slate-700",
iconClassName: "bg-slate-600 text-white ring-slate-900/10"
};
const badgeTones = {
@@ -61,9 +76,18 @@ const badgeTones = {
info: "info"
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
export function FeaturePopover({
feature,
projectId,
getAccessToken,
onClose
}: FeaturePopoverProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGisFeatureKey, setExpandedGisFeatureKey] = useState<string | null>(null);
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gisProperties = useElementGisProperties(feature, projectId, getAccessToken);
const featureKey = feature ? `${feature.layer}:${feature.id}` : null;
const gisExpanded = featureKey !== null && expandedGisFeatureKey === featureKey;
useEffect(() => () => {
if (copyResetTimeoutRef.current) {
@@ -76,7 +100,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
}
const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id);
const layerMeta = FEATURE_LAYER_META[feature.layer];
const layerMeta = FEATURE_LAYER_META[feature.layer] ?? SOURCE_GIS_LAYER_META;
const LayerIcon = layerMeta.icon;
const copied = copiedId === panelModel.id;
const canCopy = panelModel.id !== "暂无";
@@ -106,8 +130,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
return (
<aside
aria-label="要素属性"
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
"pointer-events-auto absolute left-1/2 top-[92px] z-[60] flex max-h-[calc(100dvh-116px)] -translate-x-1/2 flex-col overflow-hidden transition-[width] duration-200 motion-reduce:transition-none",
gisExpanded
? "w-[min(560px,calc(100vw-24px))]"
: "w-[min(380px,calc(100vw-24px))]",
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
@@ -148,7 +176,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
</div>
</header>
<div className="px-4 py-3">
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-3">
{panelModel.attributes.length > 0 ? (
<>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
@@ -160,6 +188,93 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
) : (
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
)}
{gisProperties.groups.length > 0 || gisProperties.status === "error" ? (
<Disclosure
key={featureKey}
className="mt-2 border-t border-slate-100 pt-1"
onToggle={(event) => {
if (event.target === event.currentTarget) {
setExpandedGisFeatureKey(event.currentTarget.open ? featureKey : null);
}
}}
label={(
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>GIS 台账属性</span>
<span className="shrink-0 text-[11px] font-normal text-slate-400">
{gisProperties.status === "error"
? "读取失败"
: `${gisProperties.groups.length} 份`}
</span>
</span>
)}
>
<div className="pb-1 pl-6">
{gisProperties.status === "error" ? (
<p className="py-3 text-xs text-rose-700">GIS 台账属性暂时无法读取</p>
) : gisProperties.groups.map((group) => {
const presentation = presentGisAttributeGroup(group);
return (
<Disclosure
key={`${group.layer_key}:${group.source_fid}`}
className="border-b border-slate-100 last:border-b-0"
label={(
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-slate-700">{group.layer_name}</span>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500">
{presentation.datasetRole}
</span>
</span>
<span className="block text-[11px] font-normal text-slate-400 tabular-nums">
{group.source_date} · {presentation.usefulCount} 个有效字段
</span>
</span>
)}
>
<div className="space-y-2 pb-3 pl-6">
<p className="text-[11px] leading-5 text-slate-500">
{presentation.description}
</p>
{presentation.summary.length > 0 ? (
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{presentation.summary.map((entry) => (
<div key={entry.key} className="min-w-0 rounded-lg border border-slate-100 bg-white/70 px-2.5 py-2">
<dt className="truncate text-[10px] font-medium text-slate-400">{entry.label}</dt>
<dd className="mt-0.5 truncate text-xs font-semibold text-slate-800" title={entry.value}>{entry.value}</dd>
</div>
))}
</dl>
) : null}
{presentation.sections.map((section) => (
<Disclosure
key={section.id}
className="border-t border-slate-100"
label={`${section.label} · ${section.entries.length} 项`}
>
<div className="pb-2 pl-6">
<FeaturePropertyList entries={section.entries} variant="popover" />
</div>
</Disclosure>
))}
<Disclosure
className="border-t border-slate-100"
label={`全部原始字段 · ${presentation.rawCount} 项`}
>
<div className="pb-2 pl-6">
<p className="pb-2 text-[11px] leading-5 text-slate-400">
包含源系统编码、空值和兼容字段,仅用于核对原始数据。
</p>
<FeaturePropertyList entries={presentation.rawEntries} variant="popover" />
</div>
</Disclosure>
</div>
</Disclosure>
);
})}
</div>
</Disclosure>
) : null}
</div>
<span className="sr-only" aria-live="polite">
@@ -1,7 +1,10 @@
import { useEffect, useState } from "react";
import { ChevronDown, Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
@@ -16,6 +19,7 @@ import type {
} from "../map/workbench-map-controller";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
@@ -24,18 +28,13 @@ type MapDevPanelProps = {
commands: WorkbenchMapCommands;
controllerState: WorkbenchMapControllerState;
detailFeature: DetailFeature | null;
flowDataSource?: "realtime" | "mock";
onClose: () => void;
};
const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
pipes: "管线",
junctions: "节点",
valves: "阀门",
reservoirs: "水库",
scada: "SCADA",
pumps: "水泵",
tanks: "水箱"
};
const GROUP_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.label])
) as Record<AvailableWaterNetworkSourceId, string>;
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
@@ -102,6 +101,7 @@ export function MapDevPanel({
commands,
controllerState,
detailFeature,
flowDataSource = "mock",
onClose
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
@@ -130,7 +130,7 @@ export function MapDevPanel({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "Escape" && !event.defaultPrevented) onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
@@ -171,8 +171,8 @@ export function MapDevPanel({
const allowedProperties = new Set(
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
([name]) => allowedProperties.has(name)
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(([name]) =>
allowedProperties.has(name)
);
if (firstNumeric) setProperty(firstNumeric[0]);
}
@@ -262,24 +262,20 @@ export function MapDevPanel({
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
<Field label="业务源">
<select
value={sourceId}
onChange={(event) => {
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
className={inputClassName}
>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
</Field>
<FieldSelect
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="Feature ID">
<input
<Input
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
className={inputClassName}
@@ -341,7 +337,9 @@ export function MapDevPanel({
<PanelSection
title="全网水流"
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
description={flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"}
>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -364,14 +362,14 @@ export function MapDevPanel({
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<input
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<input
<Input
type="number"
step="any"
value={flowVelocity}
@@ -409,13 +407,13 @@ export function MapDevPanel({
</div>
{flowSummary.total > 0 ? (
<StatusText>
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后自动显示全网 mock 流向</StatusText>
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
@@ -423,25 +421,12 @@ export function MapDevPanel({
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
>
<Field label="业务图层组">
<div className="relative">
<select
value={groupId}
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
className={cn(inputClassName, "appearance-none pr-8")}
>
{STYLE_GROUP_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
<ChevronDown
size={14}
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
/>
</div>
</Field>
<FieldSelect
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
@@ -467,19 +452,16 @@ export function MapDevPanel({
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<Field label="线型">
<select
value={draft.dash}
onChange={(event) =>
setDraft((current) => ({ ...current, dash: event.target.value }))
}
className={inputClassName}
>
<option value="original">原始</option>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
</select>
</Field>
<FieldSelect
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
@@ -555,22 +537,15 @@ export function MapDevPanel({
</div>
{showValueLabels ? (
<>
<Field label="数值属性">
<select
value={property}
onChange={(event) => setProperty(event.target.value)}
className={inputClassName}
>
{LABEL_PRESETS[groupId].options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<input
<Input
type="number"
min={0}
max={3}
@@ -580,7 +555,7 @@ export function MapDevPanel({
/>
</Field>
<Field label="单位">
<input
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
@@ -703,11 +678,10 @@ function ToggleField({
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<input
type="checkbox"
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-blue-600"
className="size-4 rounded"
/>
{label}
</label>
@@ -781,4 +755,4 @@ function StatusText({ children }: { children: React.ReactNode }) {
}
const inputClassName =
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
"h-10 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
@@ -53,7 +53,7 @@ export function MobileWorkbenchSheet({
useEffect(() => {
const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === "Escape" && !event.defaultPrevented) {
onClose();
}
};
@@ -0,0 +1,83 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord } from "../types";
import { ConditionDetailPanel } from "./scheduled-condition-detail-panel";
const baseCondition: ScheduledConditionRecord = {
id: "schedule-run-1",
kind: "condition",
taskId: "backend-schedule",
dataSource: "scheduled_tasks",
sessionId: "scheduled-condition-schedule-run-1",
title: "演示 低压调查",
summary: "后端调查运行",
status: "error",
riskLevel: "critical",
scheduledAt: "2026-09-17T02:10:00Z",
updatedAt: Date.parse("2026-09-17T02:20:00Z"),
detail: "调查执行状态:异常。",
recommendation: "请检查调查任务的输入参数与服务端执行日志。",
evidence: ["运行 ID:schedule-run-1", "运行状态:failed"],
modelName: "TJWater scheduler"
};
describe("scheduled condition backend boundaries", () => {
afterEach(cleanup);
it("shows only persisted evidence for a failed backend condition", () => {
render(
<ConditionDetailPanel
condition={baseCondition}
dataSource="scheduled_tasks"
workOrderDataSource="backend"
onContinueConversation={vi.fn()}
/>
);
expect(screen.getByText("后端持久化工况")).toBeVisible();
expect(screen.getByText("运行 ID:schedule-run-1")).toBeVisible();
expect(screen.getByText("正式工单边界")).toBeVisible();
expect(screen.getByText(/只有后端同步的工单才会显示/)).toBeVisible();
expect(screen.queryByText("演示异常判断")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "生成演示工单草稿" })).not.toBeInTheDocument();
});
it("does not synthesize workflow progress for a running backend condition", () => {
render(
<ConditionDetailPanel
condition={{ ...baseCondition, status: "running", riskLevel: "normal" }}
dataSource="scheduled_tasks"
onContinueConversation={vi.fn()}
/>
);
expect(screen.getByText("后端运行状态")).toBeVisible();
expect(screen.getByText(/不按经过时间推算百分比/)).toBeVisible();
expect(screen.queryByText(/演示执行流程/)).not.toBeInTheDocument();
expect(screen.queryByText("演示进度")).not.toBeInTheDocument();
});
it("keeps the mock work-order interaction but labels it as a local demo draft", () => {
render(
<ConditionDetailPanel
condition={{
...baseCondition,
id: "mock-scada-error",
taskId: "scada-diagnosis",
dataSource: "mock",
title: "SCADA 数据诊断",
modelName: "供水时序诊断"
}}
dataSource="mock"
onContinueConversation={vi.fn()}
/>
);
expect(screen.getByText("演示异常判断")).toBeVisible();
expect(screen.getByText("演示处置方案")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "生成演示工单草稿" }));
expect(screen.getByRole("button", { name: "演示草稿已生成" })).toBeDisabled();
expect(screen.getByText(/只保存在当前页面/)).toBeVisible();
expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument();
});
});
@@ -19,6 +19,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
StatusIcon as SemanticStatusIcon,
@@ -36,7 +37,9 @@ import type {
ScheduledConditionKpi,
ScheduledConditionRecord,
ScheduledConditionRiskLevel,
ScheduledWorkOrderItem
ScheduledWorkOrderItem,
WorkbenchRuntimeDataSource,
WorkbenchWorkOrderDataSource
} from "../types";
import {
formatDuration,
@@ -45,6 +48,7 @@ import {
getStatusActivity,
getStatusTone,
getRiskLabel,
getWorkOrderCurrentSubtask,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
@@ -78,9 +82,9 @@ type SubmittedIncidentWorkOrder = {
operations: IncidentOperation[];
};
type ExecutionStepStatus = "done" | "current" | "pending" | "confirmation";
type ExecutionStepStatus = "done" | "current" | "pending" | "blocked" | "skipped" | "confirmation";
type ExecutionBadgeTone = "active" | "confirmation" | "completed" | "pending";
type ExecutionBadgeTone = "active" | "blocked" | "confirmation" | "completed" | "pending";
type ExecutionStepMeta = {
label: string;
@@ -98,6 +102,8 @@ type ExecutionStep = {
type ConditionDetailPanelProps = {
condition: ScheduledConditionItem;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
};
@@ -115,12 +121,12 @@ export function StatusPill({
<StatusBadge
tone={
isWorkOrder
? getWorkOrderStageTone(currentStage?.id)
? getWorkOrderStageTone(currentStage?.status)
: getStatusTone(condition.status)
}
activity={
isWorkOrder
? getWorkOrderStageActivity(currentStage?.id)
? getWorkOrderStageActivity(currentStage?.status)
: getStatusActivity(condition.status)
}
className={className}
@@ -130,16 +136,26 @@ export function StatusPill({
);
}
export function ConditionDetailPanel({ condition, onContinueConversation }: ConditionDetailPanelProps) {
export function ConditionDetailPanel({
condition,
dataSource = "mock",
workOrderDataSource = "mock",
onContinueConversation
}: ConditionDetailPanelProps) {
const detailKey =
condition.kind === "work_order"
? `${condition.id}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
? `${condition.id}-${getWorkOrderCurrentSubtask(condition)?.id ?? "none"}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
: condition.id;
const content =
condition.kind === "work_order" ? (
<WorkOrderDetailPanel condition={condition} />
<WorkOrderDetailPanel condition={condition} dataSource={workOrderDataSource} />
) : (
<ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />
<ConditionRecordDetailPanel
condition={condition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={onContinueConversation}
/>
);
return (
@@ -160,12 +176,17 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
function ConditionRecordDetailPanel({
condition,
dataSource,
workOrderDataSource,
onContinueConversation
}: {
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
}) {
const insight = getConditionAnalysisInsight(condition);
const backendCondition = (condition.dataSource ?? dataSource) !== "mock";
const insight = backendCondition ? null : getConditionAnalysisInsight(condition);
const [submittedWorkOrder, setSubmittedWorkOrder] = useState<SubmittedIncidentWorkOrder | null>(null);
const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
@@ -177,8 +198,8 @@ function ConditionRecordDetailPanel({
showMapNotice({
id: `incident-work-order-${condition.id}`,
tone: "success",
title: "系统工单已提交",
message: `${nextWorkOrder.code} 已通知 ${nextWorkOrder.notifiedTeams.join("、")}。`
title: "演示工单草稿已生成",
message: `${nextWorkOrder.code} 仅保存在当前页面,不会写入或通知真实工单系统。`
});
}
@@ -202,7 +223,17 @@ function ConditionRecordDetailPanel({
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
>
{condition.status === "running" ? (
<RunningConditionBody condition={condition} />
backendCondition ? (
<PersistedRunningConditionBody condition={condition} />
) : (
<RunningConditionBody condition={condition} />
)
) : backendCondition ? (
<PersistedConditionBody
condition={condition}
workOrderDataSource={workOrderDataSource}
onContinueConversation={onContinueConversation}
/>
) : (
<>
<ConditionStatusReport condition={condition} riskLevel={riskLevel} />
@@ -210,12 +241,12 @@ function ConditionRecordDetailPanel({
<>
<DecisionSummaryBlock
icon={ShieldAlert}
title="异常判断"
title="演示异常判断"
iconClassName="status-tone-danger text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
summary={insight!.summary}
notes={insight!.notes}
/>
<SolutionOptionsList options={insight.solutionOptions ?? []} />
<SolutionOptionsList options={insight!.solutionOptions ?? []} />
<IncidentWorkOrderPanel
condition={condition}
submittedWorkOrder={submittedWorkOrder}
@@ -233,12 +264,12 @@ function ConditionRecordDetailPanel({
<>
<DecisionSummaryBlock
icon={Lightbulb}
title="关注说明"
title="演示关注说明"
iconClassName="status-tone-warning text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
summary={insight!.summary}
notes={insight!.notes}
/>
<EscalationCriteriaList items={insight.escalationCriteria ?? []} />
<EscalationCriteriaList items={insight!.escalationCriteria ?? []} />
<DecisionActionStrip
icon={Bot}
title="复核建议"
@@ -252,7 +283,7 @@ function ConditionRecordDetailPanel({
<EvidenceList items={condition.evidence ?? []} />
<DecisionActionStrip
icon={Bot}
title="Agent 建议"
title="演示 Agent 建议"
value={condition.recommendation ?? "当前工况暂无新增处置建议,保持观察即可。"}
actionLabel="继续对话"
onAction={onContinueConversation}
@@ -269,7 +300,88 @@ function ConditionRecordDetailPanel({
);
}
function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
function PersistedConditionBody({
condition,
workOrderDataSource,
onContinueConversation
}: {
condition: ScheduledConditionRecord;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
}) {
return (
<>
<SourceBoundaryNotice
title="后端持久化工况"
description="这里只展示后端明确记录的状态、时间和证据;界面不会按标题或风险级别生成专业判断、阈值或处置方案。"
/>
<EvidenceList items={condition.evidence ?? []} />
{condition.riskLevel === "critical" ? (
<SourceBoundaryNotice
title="正式工单边界"
description={workOrderDataSource === "backend"
? "正式工单列表已接入后端;只有后端同步的工单才会显示,当前页面不会自动创建或提交工单。"
: "正式工单来源尚未配置;当前页面不会用演示草稿冒充真实工单。"}
/>
) : null}
<DecisionActionStrip
icon={Bot}
title="基于后端记录继续复核"
value={condition.recommendation ?? "后端未提供处置建议,可将现有状态和证据发送给 Agent 继续复核。"}
actionLabel="继续对话"
onAction={onContinueConversation}
/>
</>
);
}
function PersistedRunningConditionBody({
condition
}: {
condition: ScheduledConditionRecord;
}) {
return (
<>
<SourceBoundaryNotice
title="后端运行状态"
description="后端当前记录为执行中。界面等待新的持久化状态,不按经过时间推算百分比、当前步骤或下一步。"
/>
<EvidenceList items={condition.evidence ?? []} />
<DecisionActionStrip
icon={Bot}
title="等待后端更新"
value={condition.detail ?? "任务仍在后端执行。"}
actionLabel="执行中"
actionDisabled
actionTitle="后端任务完成后可继续复核"
/>
</>
);
}
function SourceBoundaryNotice({
title,
description
}: {
title: string;
description: string;
}) {
return (
<DetailSectionCard>
<SectionTitle icon={ShieldAlert} title={title} />
<p className="mt-2 text-xs leading-5 text-slate-700">{description}</p>
</DetailSectionCard>
);
}
function WorkOrderDetailPanel({
condition,
dataSource
}: {
condition: ScheduledWorkOrderItem;
dataSource: WorkbenchWorkOrderDataSource;
}) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const currentStage = getWorkOrderCurrentStage(condition);
return (
@@ -281,12 +393,19 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
iconClassName="bg-blue-50 text-blue-700"
/>
<DetailDecisionBody>
<WorkOrderLifecyclePanel condition={condition} />
<WorkOrderReplyRequirements condition={condition} />
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "工单演示回退"}
description={dataSource === "backend"
? "工单的子任务、阶段、责任人和复令要求均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
/>
<WorkOrderSubtasksPanel condition={condition} />
<DecisionActionStrip
icon={CalendarClock}
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
title={currentSubtask && currentStage
? `${currentSubtask.title} · ${currentStage.title}`
: "工单流转"}
value={condition.recommendation ?? "按子任务及其阶段跟踪现场处置闭环。"}
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
@@ -458,7 +577,7 @@ function SolutionOptionsList({
}) {
return (
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionEyebrow icon={Route} label="处置方案" />
<SectionEyebrow icon={Route} label="演示处置方案" />
<div className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
{options.map((option, index) => (
<article key={option.id} className="px-2.5 py-2">
@@ -501,32 +620,32 @@ function IncidentWorkOrderPanel({
const operations = submittedWorkOrder?.operations ?? createIncidentOperations(condition);
const submitted = Boolean(submittedWorkOrder);
const steps: ExecutionStep[] = [
{ id: "analysis", title: "事件分析", status: "done", description: "汇总异常证据、影响分区和风险等级。" },
{ id: "solution", title: "形成方案", status: "done", description: "生成阀门、泵站和现场复核操作清单。" },
{ id: "confirm", title: "人工确认", status: submitted ? "done" : "confirmation", description: "调度员确认影响范围和操作边界。" },
{ id: "submit", title: "提交工单", status: submitted ? "done" : "pending", description: "写入系统工单并通知执行岗位。" }
{ id: "analysis", title: "演示事件分析", status: "done", description: "根据前端演示数据生成示例说明。" },
{ id: "solution", title: "演示方案", status: "done", description: "生成虚构的阀门、泵站和现场复核清单。" },
{ id: "confirm", title: "演示确认", status: submitted ? "done" : "confirmation", description: "仅确认当前页面中的演示草稿。" },
{ id: "submit", title: "保存演示草稿", status: submitted ? "done" : "pending", description: "只更新当前页面状态,不写入或通知真实工单系统。" }
];
return (
<TaskExecutionPanel
icon={ClipboardCheck}
title="任务执行"
badge={submitted ? "已提交" : "待人工确认"}
title="演示工单草稿"
badge={submitted ? "草稿已生成" : "演示回退"}
badgeTone={submitted ? "completed" : "confirmation"}
summary={
submitted
? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。`
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。"
? `${submittedWorkOrder?.code} 只保存在当前页面,没有进入真实工单流转。`
: "该区域仅用于保留原有演示交互;生成草稿不会写入后端,也不会通知任何岗位。"
}
meta={
submittedWorkOrder
? [
{ label: "系统工单", value: submittedWorkOrder.code },
{ label: "通知对象", value: submittedWorkOrder.notifiedTeams.join("、") }
{ label: "演示草稿", value: submittedWorkOrder.code },
{ label: "模拟对象", value: submittedWorkOrder.notifiedTeams.join("、") }
]
: [
{ label: "来源", value: condition.title },
{ label: "状态", value: "待人工确认" },
{ label: "状态", value: "演示回退" },
{ label: "风险等级", value: getRiskLabel(condition.riskLevel) }
]
}
@@ -547,7 +666,7 @@ function IncidentWorkOrderPanel({
)}
>
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
<span>{submitted ? "工单已提交" : "人工确认并提交工单"}</span>
<span>{submitted ? "演示草稿已生成" : "生成演示工单草稿"}</span>
</button>
</TaskExecutionPanel>
);
@@ -567,7 +686,7 @@ function IncidentOperationList({
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionEyebrow icon={Wrench} label={operation.action} />
<StatusBadge tone={submitted ? "success" : "neutral"}>
{submitted ? "已通知" : operation.elementType}
{submitted ? "演示已记录" : operation.elementType}
</StatusBadge>
</div>
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
@@ -822,6 +941,10 @@ function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
return "info";
}
if (tone === "blocked") {
return "danger";
}
return "neutral";
}
@@ -838,6 +961,10 @@ function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
return "warning";
}
if (status === "blocked") {
return "danger";
}
return "neutral";
}
@@ -854,6 +981,14 @@ function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
return "待人工确认";
}
if (status === "blocked") {
return "受阻";
}
if (status === "skipped") {
return "已跳过";
}
return "待处理";
}
@@ -906,14 +1041,14 @@ function RunningWorkflowPanel({
return (
<TaskExecutionPanel
title={workflow.title}
badge="执行中"
title={`演示执行流程 · ${workflow.title}`}
badge="演示中"
badgeTone="active"
summary={`${currentStep.title}:${currentStep.detail}`}
summary={`按前端时间轴演示:${currentStep.title}:${currentStep.detail}`}
meta={[
{ label: "任务对象", value: condition.title },
{ label: "当前进度", value: `${workflowState.progress}%` },
{ label: "下一步", value: nextStep?.title ?? workflow.reportLabel }
{ label: "演示进度", value: `${workflowState.progress}%` },
{ label: "演示下一步", value: nextStep?.title ?? workflow.reportLabel }
]}
steps={steps}
/>
@@ -1034,7 +1169,7 @@ function TaskExecutionStepRow({
<CheckCircle2 size={14} />
) : step.status === "current" ? (
<Loader2 size={14} />
) : step.status === "confirmation" ? (
) : step.status === "confirmation" || step.status === "blocked" ? (
<AlertTriangle size={14} />
) : (
<Clock3 size={14} />
@@ -1066,67 +1201,112 @@ function TaskExecutionStepRow({
);
}
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const duration = formatDuration(condition.durationMinutes, condition.status);
const currentStage = getWorkOrderCurrentStage(condition);
const steps = condition.stages.map((stage) => {
const awaitingReply = stage.id === "reply" && stage.status === "current";
return {
id: stage.id,
title: stage.title,
status: awaitingReply ? ("confirmation" as const) : stage.status,
statusLabel: awaitingReply ? "待复令" : undefined,
description: stage.description,
meta: [
{ label: "责任人", value: stage.owner },
{ label: "时间", value: stage.timeLabel }
]
};
});
function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const completedCount = condition.subtasks.filter((subtask) =>
subtask.status === "completed" || subtask.status === "cancelled"
).length;
return (
<TaskExecutionPanel
icon={ClipboardList}
title="任务执行"
badge={currentStage ? currentStage.title : condition.code}
badgeTone={currentStage?.id === "reply" ? "confirmation" : currentStage?.id === "execution" ? "active" : "pending"}
summary={condition.detail ?? condition.summary}
meta={[
{ label: "来源", value: condition.source },
{ label: "位置", value: condition.location },
{ label: "派发人", value: condition.dispatcher },
{ label: "执行班组", value: condition.assignee },
{ label: "优先级", value: condition.priority === "urgent" ? "紧急" : "普通" },
{ label: "预计耗时", value: duration }
]}
steps={steps}
/>
<DetailSectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={ListChecks} title="子任务" />
<StatusBadge tone={completedCount === condition.subtasks.length ? "success" : "info"}>
{completedCount}/{condition.subtasks.length} 完成
</StatusBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
<ReportMetaTile label="来源" value={condition.source} />
<ReportMetaTile label="派发人" value={condition.dispatcher} />
<ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<ReportMetaTile label="预计耗时" value={formatDuration(condition.durationMinutes, condition.status)} />
</div>
<div className="mt-2 grid gap-2">
{condition.subtasks.map((subtask, index) => {
const active = subtask.id === currentSubtask?.id;
const steps: ExecutionStep[] = subtask.stages.map((stage) => {
const awaitingReply = stage.id === "reply" && stage.status === "current";
return {
id: `${subtask.id}-${stage.id}`,
title: stage.title,
status: awaitingReply ? "confirmation" : stage.status,
statusLabel: awaitingReply ? "待复令" : undefined,
description: stage.description,
meta: [
{ label: "责任人", value: stage.owner },
{ label: "时间", value: stage.timeLabel }
]
};
});
return (
<Disclosure
key={subtask.id}
className="surface-control rounded-xl border border-slate-200/70 [&>summary]:px-3 [&>summary]:py-2.5"
initiallyOpen={active}
label={(
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 truncate">{index + 1}. {subtask.title}</span>
<StatusBadge
tone={getWorkOrderSubtaskTone(subtask.status)}
activity={subtask.status === "running" ? "live" : "static"}
>
{getWorkOrderSubtaskStatusLabel(subtask.status)}
</StatusBadge>
</span>
)}
>
<div className="border-t border-slate-200/70 px-3 py-3">
<p className="text-xs leading-5 text-slate-700">{subtask.summary}</p>
<div className="mt-2 grid gap-1 text-xs leading-5 text-slate-600 sm:grid-cols-2">
<span><b className="text-slate-400">位置:</b>{subtask.location}</span>
<span><b className="text-slate-400">班组:</b>{subtask.assignee}</span>
<span><b className="text-slate-400">窗口:</b><span className="font-mono">{formatTimeRange(subtask.scheduledAt, subtask.durationMinutes)}</span></span>
<span><b className="text-slate-400">复令:</b>完成后 {subtask.replyWindowMinutes} 分钟内</span>
</div>
<ol className="mt-3 grid gap-2">
{steps.map((step, stepIndex) => (
<TaskExecutionStepRow
key={step.id}
step={step}
isLast={stepIndex === steps.length - 1}
/>
))}
</ol>
{subtask.replyRequirements.length > 0 ? (
<ul className="mt-3 grid gap-1.5 border-t border-slate-200/70 pt-3">
{subtask.replyRequirements.map((item) => (
<li key={item} className="flex gap-2 text-xs leading-5 text-slate-700">
<CheckCircle2 className="mt-0.5 shrink-0 text-blue-600" size={14} />
<span>{item}</span>
</li>
))}
</ul>
) : null}
</div>
</Disclosure>
);
})}
</div>
</DetailSectionCard>
);
}
function WorkOrderReplyRequirements({ condition }: { condition: ScheduledWorkOrderItem }) {
return (
<DetailSectionCard>
<SectionTitle icon={ListChecks} title="复令要求" />
<div className="mt-2 grid grid-cols-[72px_1fr] gap-2 text-xs leading-5">
<span className="font-semibold text-slate-400">执行窗口</span>
<span className="font-mono font-semibold text-slate-800">{formatTimeRange(condition.scheduledAt, condition.durationMinutes)}</span>
<span className="font-semibold text-slate-400">复令时限</span>
<span className="text-slate-700">执行完成后 {condition.replyWindowMinutes} 分钟内</span>
</div>
<ul className="mt-2 space-y-1.5">
{condition.replyRequirements.map((item, index) => (
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
{index + 1}
</span>
<span>{item}</span>
</li>
))}
</ul>
</DetailSectionCard>
);
function getWorkOrderSubtaskTone(status: ScheduledWorkOrderItem["subtasks"][number]["status"]): StatusTone {
if (status === "completed") return "success";
if (status === "running") return "info";
if (status === "blocked") return "danger";
return "neutral";
}
function getWorkOrderSubtaskStatusLabel(status: ScheduledWorkOrderItem["subtasks"][number]["status"]) {
return {
pending: "待处理",
running: "处理中",
blocked: "受阻",
completed: "已完成",
cancelled: "已取消"
}[status];
}
function RunningEvidencePreview({ items }: { items: string[] }) {
@@ -3,8 +3,10 @@ import {
getStatusActivity,
getStatusTone,
getWorkOrderStageActivity,
getWorkOrderStageTone
getWorkOrderStageTone,
getWorkOrderCurrentSubtask
} from "./scheduled-condition-feed-utils";
import type { ScheduledWorkOrderItem } from "../types";
describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => {
@@ -27,16 +29,74 @@ describe("scheduled condition status presentation", () => {
expect(getStatusActivity("awaiting_reply")).toBe("static");
});
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
it("maps work-order stage states to the shared semantic language", () => {
expect({
dispatch: getWorkOrderStageTone("dispatch"),
execution: getWorkOrderStageTone("execution"),
reply: getWorkOrderStageTone("reply")
done: getWorkOrderStageTone("done"),
current: getWorkOrderStageTone("current"),
pending: getWorkOrderStageTone("pending"),
blocked: getWorkOrderStageTone("blocked"),
skipped: getWorkOrderStageTone("skipped")
}).toEqual({
dispatch: "neutral",
execution: "info",
reply: "warning"
done: "success",
current: "info",
pending: "neutral",
blocked: "danger",
skipped: "neutral"
});
expect(getWorkOrderStageActivity("execution")).toBe("loading");
expect(getWorkOrderStageActivity("current")).toBe("loading");
});
it("prefers an executing subtask over a pending subtask at its dispatch stage", () => {
const workOrder = {
id: "work-order-1",
kind: "work_order",
code: "WO-1",
scheduledAt: "2026-09-17T10:00:00+08:00",
title: "多子任务工单",
summary: "测试当前子任务选择。",
status: "running",
riskLevel: "normal",
updatedAt: 1,
durationMinutes: 30,
source: "测试",
dispatcher: "调度中心",
priority: "normal",
subtasks: [
createSubtask("pending-subtask", "pending", "dispatch"),
createSubtask("running-subtask", "running", "execution")
]
} satisfies ScheduledWorkOrderItem;
expect(getWorkOrderCurrentSubtask(workOrder)?.id).toBe("running-subtask");
});
});
function createSubtask(
id: string,
status: "pending" | "running",
currentStage: "dispatch" | "execution"
) {
return {
id,
title: id,
summary: id,
status,
scheduledAt: "2026-09-17T10:00:00+08:00",
location: "演示点位",
assignee: "演示班组",
durationMinutes: 15,
replyWindowMinutes: 10,
stages: [
{
id: currentStage,
sequence: 1,
title: currentStage,
status: "current" as const,
owner: "演示班组",
timeLabel: "10:00",
description: "当前阶段"
}
],
replyRequirements: []
};
}
@@ -52,16 +52,20 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
};
const workOrderStageTones = {
dispatch: "neutral",
execution: "info",
reply: "warning"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
done: "success",
current: "info",
pending: "neutral",
blocked: "danger",
skipped: "neutral"
} satisfies Record<ScheduledWorkOrderStage["status"], StatusTone>;
const workOrderStageActivities = {
dispatch: "static",
execution: "loading",
reply: "static"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
done: "static",
current: "loading",
pending: "static",
blocked: "static",
skipped: "static"
} satisfies Record<ScheduledWorkOrderStage["status"], StatusActivity>;
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"running",
@@ -169,21 +173,36 @@ export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
return "正常";
}
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
export function getWorkOrderCurrentSubtask(condition: ScheduledWorkOrderItem) {
return (
condition.stages.find((stage) => stage.status === "current") ??
condition.stages.find((stage) => stage.status === "pending") ??
condition.stages.at(-1) ??
condition.subtasks.find((subtask) => subtask.status === "running") ??
condition.subtasks.find((subtask) => subtask.status === "blocked") ??
condition.subtasks.find((subtask) =>
subtask.stages.some((stage) => stage.status === "current")
) ??
condition.subtasks.find((subtask) => subtask.status === "pending") ??
condition.subtasks.at(-1) ??
null
);
}
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageTones[stageId] : "neutral";
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
const subtask = getWorkOrderCurrentSubtask(condition);
return (
subtask?.stages.find((stage) => stage.status === "current") ??
subtask?.stages.find((stage) => stage.status === "blocked") ??
subtask?.stages.find((stage) => stage.status === "pending") ??
subtask?.stages.at(-1) ??
null
);
}
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageActivities[stageId] : "static";
export function getWorkOrderStageTone(status?: ScheduledWorkOrderStage["status"]) {
return status ? workOrderStageTones[status] : "neutral";
}
export function getWorkOrderStageActivity(status?: ScheduledWorkOrderStage["status"]) {
return status ? workOrderStageActivities[status] : "static";
}
export function getStatusIcon(status: ScheduledConditionItemStatus) {
@@ -11,7 +11,11 @@ import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
import type {
ScheduledConditionItem,
WorkbenchRuntimeDataSource,
WorkbenchWorkOrderDataSource
} from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import { cn } from "@/shared/ui/cn";
import {
@@ -58,6 +62,8 @@ type ScheduledConditionFeedProps = {
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
visible?: boolean;
selectedConditionId?: string | null;
onExpandedChange?: (expanded: boolean) => void;
@@ -80,6 +86,8 @@ export function ScheduledConditionFeed({
expanded: controlledExpanded,
focusRequest,
loading = false,
dataSource = "mock",
workOrderDataSource = "mock",
visible = true,
selectedConditionId: controlledSelectedConditionId,
onExpandedChange,
@@ -142,13 +150,24 @@ export function ScheduledConditionFeed({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const conditionSourceLabel = {
mock: "工况演示数据",
backend: "后端当天数据",
scheduled_tasks: "后端定时任务"
}[dataSource];
const workOrderSourceLabel = {
mock: "工单演示回退",
backend: "后端工单",
none: "未接入工单"
}[workOrderDataSource];
const sourceLabel = `${conditionSourceLabel} · ${workOrderSourceLabel}`;
const headerSummary = loading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条 · ${sourceLabel}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`;
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
@@ -256,7 +275,10 @@ export function ScheduledConditionFeed({
}
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(
condition,
condition.dataSource ?? dataSource
)))
.then(() => {
showMapNotice({
id: "scheduled-condition-generated-session",
@@ -381,6 +403,8 @@ export function ScheduledConditionFeed({
) : expanded && selectedCondition ? (
<ConditionDetailPanel
condition={selectedCondition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
/>
) : null}
@@ -849,12 +873,16 @@ function ConditionTimelineRow({
}: ConditionTimelineRowProps) {
const Icon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.status : undefined;
const tone = isWorkOrder
? getWorkOrderStageTone(workOrderStage)
? condition.status === "running"
? getWorkOrderStageTone(workOrderStage)
: getStatusTone(condition.status)
: getStatusTone(condition.status);
const activity = isWorkOrder
? getWorkOrderStageActivity(workOrderStage)
? condition.status === "running"
? getWorkOrderStageActivity(workOrderStage)
: getStatusActivity(condition.status)
: getStatusActivity(condition.status);
return (
@@ -1,5 +1,4 @@
import {
Activity,
Camera,
Circle,
Copy,
@@ -15,6 +14,7 @@ import {
Route,
Ruler,
Share2,
Square,
Trash2,
Undo2,
Waypoints,
@@ -28,7 +28,6 @@ import {
MapDrawToolbar,
MapLayerPanel,
MapLegendList,
MapMetricTile,
MapMeasurePanel,
MapPanelSection,
type BaseLayerOption,
@@ -233,7 +232,8 @@ export function ToolbarPanel({
title="图层"
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
icon={Icon}
widthClassName={panelWidthClassName}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<MapLayerPanel
@@ -248,8 +248,23 @@ export function ToolbarPanel({
}
if (renderedToolId === "measure") {
const measureStatus = getMeasureStatus({
enabled: measureEnabled,
measuring,
hasPoints: hasMeasurePoints,
mode: activeMeasureModeId
});
return (
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapControlPanel
key="measure"
title="测量"
description="距离、面积与管段测量"
icon={Icon}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<MapMeasurePanel
modes={MEASURE_MODES}
activeModeId={activeMeasureModeId}
@@ -259,20 +274,21 @@ export function ToolbarPanel({
metrics={measureResult.metrics}
units={MEASURE_UNITS}
activeUnitId={activeMeasureUnitId}
status={measureStatus}
actions={[
{
id: measuring ? "stop" : "start",
icon: Ruler,
icon: measuring ? Square : Ruler,
label: measuring ? "停止测量" : "开始测量",
description:
activeMeasureModeId === "segment"
? measuring
? "暂停管线选择"
: "点击管线选择资产"
: measuring
? "暂停地图取点"
: "在地图上连续点击取点",
variant: measuring ? "default" : "primary",
measuring
? "暂停并保留当前读数"
: activeMeasureModeId === "segment"
? "在地图中选择管线"
: activeMeasureModeId === "area"
? "在地图中圈定范围"
: "在地图中连续取点",
variant: measuring ? "active" : "primary",
disabled: !measureEnabled,
onClick: measuring ? onStopMeasure : onStartMeasure
},
@@ -303,8 +319,16 @@ export function ToolbarPanel({
if (renderedToolId === "analysis") {
return (
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
<AnalysisPanel legendItems={legendItems} />
<MapControlPanel
key="analysis"
title="图例"
description={`${legendItems.length} 项当前地图符号`}
icon={Icon}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<LegendPanel legendItems={legendItems} />
</MapControlPanel>
);
}
@@ -368,21 +392,68 @@ export function ToolbarPanel({
);
}
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
return (
<div className="space-y-3">
<MapPanelSection title="图例">
<MapLegendList items={legendItems} showStatusDot />
</MapPanelSection>
function getMeasureStatus({
enabled,
measuring,
hasPoints,
mode
}: {
enabled: boolean;
measuring: boolean;
hasPoints: boolean;
mode: WorkbenchMeasureMode;
}) {
if (!enabled) {
return {
label: "地图未就绪",
description: "等待地图加载后再开始测量",
tone: "disabled" as const
};
}
<MapPanelSection title="当前覆盖">
<div className="grid grid-cols-2 gap-2">
<MapMetricTile label="影响范围" value="未开启" />
<MapMetricTile label="风险预览" value="待生成" />
</div>
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
</MapPanelSection>
if (measuring) {
return {
label: "测量中",
description:
mode === "segment"
? "点击地图中的管线追加资产"
: mode === "area"
? "连续点击范围顶点,停止后保留读数"
: "连续点击地图取点,停止后保留读数",
tone: "active" as const
};
}
if (hasPoints) {
return {
label: "读数已保留",
description: "可继续测量、复制结果或清除",
tone: "ready" as const
};
}
return {
label: "等待开始",
description:
mode === "segment"
? "开始后点击管线选择资产"
: mode === "area"
? "开始后连续点击范围顶点"
: "开始后连续点击地图取点",
tone: "idle" as const
};
}
function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
return (
<div className="space-y-2">
<MapLegendList items={legendItems} />
<div role="note" className="surface-control rounded-xl border px-3 py-2.5">
<p className="text-[11px] font-semibold text-slate-700">图例随图层显隐同步</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
管线颜色和线宽表示口径,设施颜色表示类型,风险色仅用于分析覆盖。
</p>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
import type { ComponentProps, ReactNode } from "react";
import { AlertCircle, CheckCircle2, CircleHelp, Info, TriangleAlert } from "lucide-react";
import { cn } from "@/shared/ui/cn";
export type WorkbenchTone = "neutral" | "info" | "success" | "warning" | "danger" | "teal";
const sectionTone: Record<WorkbenchTone, string> = {
neutral: "bg-slate-100 text-slate-600",
info: "bg-blue-50 text-blue-700",
success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700",
danger: "bg-rose-50 text-rose-700",
teal: "bg-cyan-50 text-cyan-800"
};
const noticeTone: Record<Exclude<WorkbenchTone, "teal">, string> = {
neutral: "border-slate-300 bg-slate-50 text-slate-700",
info: "border-blue-500 bg-blue-50/80 text-blue-950",
success: "border-emerald-500 bg-emerald-50/80 text-emerald-950",
warning: "border-amber-500 bg-amber-50/90 text-amber-950",
danger: "border-rose-500 bg-rose-50/90 text-rose-950"
};
const noticeIcon = {
neutral: CircleHelp,
info: Info,
success: CheckCircle2,
warning: TriangleAlert,
danger: AlertCircle
};
export function WorkbenchSection({
title,
description,
icon,
meta,
actions,
tone = "neutral",
bodyClassName,
className,
children,
...props
}: ComponentProps<"section"> & {
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
meta?: ReactNode;
actions?: ReactNode;
tone?: WorkbenchTone;
bodyClassName?: string;
}) {
return (
<section
className={cn(
"overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
className
)}
{...props}
>
<div className="flex min-h-14 items-start gap-3 border-b border-slate-200/75 px-3.5 py-3">
{icon ? (
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
{icon}
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
{meta}
</div>
{description ? <p className="mt-0.5 text-xs leading-5 text-slate-500">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
<div className={cn("p-3.5", bodyClassName)}>{children}</div>
</section>
);
}
export function WorkbenchNotice({
tone = "info",
title,
icon,
className,
children,
...props
}: ComponentProps<"div"> & {
tone?: Exclude<WorkbenchTone, "teal">;
title?: ReactNode;
icon?: ReactNode;
}) {
const Icon = noticeIcon[tone];
return (
<div
className={cn(
"flex min-w-0 gap-2.5 rounded-r-lg border-l-2 px-3 py-2.5 text-xs leading-5",
noticeTone[tone],
className
)}
{...props}
>
<span className="mt-0.5 grid size-4 shrink-0 place-items-center" aria-hidden="true">
{icon ?? <Icon size={15} />}
</span>
<div className="min-w-0 flex-1 break-words">
{title ? <p className="font-semibold">{title}</p> : null}
<div className={cn(title && "mt-0.5")}>{children}</div>
</div>
</div>
);
}
export function WorkbenchEmptyState({
icon,
title,
description,
action,
className
}: {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn("rounded-xl border border-dashed border-slate-300 bg-slate-50/70 px-4 py-5 text-center", className)}>
{icon ? <span className="mx-auto grid size-8 place-items-center rounded-full bg-white text-slate-500 shadow-xs">{icon}</span> : null}
<p className={cn("text-xs font-semibold text-slate-700", icon && "mt-2")}>{title}</p>
{description ? <p className="mx-auto mt-1 max-w-72 text-[11px] leading-5 text-slate-500">{description}</p> : null}
{action ? <div className="mt-3 flex justify-center">{action}</div> : null}
</div>
);
}
@@ -5,14 +5,15 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
Columns2,
Download,
FileText,
Keyboard,
LogOut,
PlayCircle,
Map,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
X,
UserRound,
type LucideIcon
} from "lucide-react";
@@ -35,12 +36,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import type { WorkbenchAlert, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
compactHeaderTextClassName,
@@ -48,44 +47,36 @@ import {
headerControlIconClassName
} from "./workbench-top-bar-styles";
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
active: "运行中",
draft: "草案",
review: "待复核"
};
const scenarioStatusTones = {
active: "info",
draft: "neutral",
review: "warning"
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
);
const menuReadableRowClassName =
"surface-well focus:bg-blue-50 focus:text-slate-950";
export function ScenarioMenu({
export function AnalysisViewMenu({
open,
onOpenChange,
scenarios,
activeScenarioId,
activeScenarioName,
onSelectScenario,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport
title,
subtitle,
sourceLabel,
splitAvailable,
hasSpatialView,
onShowResult,
onShowMapSplit,
onShowMapOnly,
onClose
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
activeScenarioName: string;
onSelectScenario: (scenarioId: string) => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
title: string;
subtitle: string;
sourceLabel: string;
splitAvailable: boolean;
hasSpatialView: boolean;
onShowResult: () => void;
onShowMapSplit: () => void;
onShowMapOnly: () => void;
onClose: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
@@ -93,52 +84,29 @@ export function ScenarioMenu({
<button
type="button"
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
aria-label="切换模拟方案"
aria-label={`当前分析视图:${title}`}
>
<span className={headerControlIconClassName}>
<SlidersHorizontal size={14} aria-hidden="true" />
<Activity size={14} aria-hidden="true" />
</span>
<span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]">
分析视图 · {title}
</span>
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
<MenuSectionLabel>场景列表</MenuSectionLabel>
<DropdownMenuRadioGroup
value={activeScenarioId}
onValueChange={(nextScenarioId) => {
onSelectScenario(nextScenarioId);
onOpenChange(false);
}}
>
{scenarios.map((scenario) => (
<DropdownMenuRadioItem
key={scenario.id}
value={scenario.id}
className={cn(
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
{scenarioStatusLabels[scenario.status]}
</StatusBadge>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<MenuHeader title={title} meta={`${subtitle} · ${sourceLabel}`} />
<MenuSectionLabel>查看方式</MenuSectionLabel>
<MenuAction icon={FileText} label="查看完整成果" description="打开指标、图表和结果表" tone="primary" onSelect={onShowResult} />
{splitAvailable ? (
<MenuAction icon={Columns2} label="半屏显示" description="分析报告与地图各占半屏" onSelect={onShowMapSplit} />
) : null}
{hasSpatialView ? (
<MenuAction icon={Map} label="仅看地图" description="保留当前分析图层并展开地图" onSelect={onShowMapOnly} />
) : null}
<MenuSeparator />
<MenuSectionLabel>场景命令</MenuSectionLabel>
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
<MenuAction icon={X} label="关闭分析视图" description="移除当前成果及其地图图层" onSelect={onClose} />
</DropdownMenuContent>
</DropdownMenu>
);
@@ -148,6 +116,7 @@ export function AlertMenu({
open,
onOpenChange,
alerts,
sourceLabel,
compact = false,
conditionFeedVisible,
onSelectAlert,
@@ -157,6 +126,7 @@ export function AlertMenu({
open: boolean;
onOpenChange: (open: boolean) => void;
alerts: WorkbenchAlert[];
sourceLabel: string;
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
@@ -164,6 +134,8 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time;
return (
@@ -175,7 +147,9 @@ export function AlertMenu({
"group relative px-2",
headerControlButtonClassName,
hasAlerts && !compact &&
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact &&
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
@@ -183,7 +157,9 @@ export function AlertMenu({
<span
className={cn(
headerControlIconClassName,
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
hasAlerts
? cn("status-icon", hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning")
: "bg-transparent!"
)}
>
<Bell size={14} aria-hidden="true" />
@@ -191,7 +167,7 @@ export function AlertMenu({
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
{compact ? (
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
tone={alertSummaryTone}
className={cn(
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
!hasAlerts && "border-transparent! bg-transparent!"
@@ -201,7 +177,7 @@ export function AlertMenu({
</StatusBadge>
) : (
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
tone={alertSummaryTone}
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
>
{alerts.length}
@@ -214,32 +190,39 @@ export function AlertMenu({
count={alerts.length}
latestAlertTime={latestAlertTime}
conditionFeedVisible={conditionFeedVisible}
sourceLabel={sourceLabel}
tone={hasAlerts ? alertSummaryTone : "success"}
/>
<MenuSectionLabel>待复核工况</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => (
<DropdownMenuItem
key={alert.id}
className={cn(
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<StatusDot tone="danger" size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
return (
<DropdownMenuItem
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)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<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-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
);
}) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
@@ -342,11 +325,15 @@ export function UserMenu({
function AlertQueueSummary({
count,
latestAlertTime,
conditionFeedVisible
conditionFeedVisible,
sourceLabel,
tone
}: {
count: number;
latestAlertTime?: string;
conditionFeedVisible: boolean;
sourceLabel: string;
tone: StatusTone;
}) {
const hasAlerts = count > 0;
@@ -355,7 +342,7 @@ function AlertQueueSummary({
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<SemanticStatusIcon
tone={hasAlerts ? "danger" : "success"}
tone={tone}
size="md"
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
@@ -366,9 +353,10 @@ function AlertQueueSummary({
<p className="mt-0.5 text-xs leading-5 text-slate-500">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
</p>
<p className="mt-0.5 text-xs leading-4 text-slate-400">来源:{sourceLabel}</p>
</div>
</div>
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
<StatusBadge tone={tone}>
{hasAlerts ? "需复核" : "正常"}
</StatusBadge>
</div>
@@ -13,4 +13,4 @@ export const compactHeaderControlButtonClassName =
export const headerControlIconClassName =
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
export const compactHeaderTextClassName = "hidden 2xl:inline";
export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
@@ -2,18 +2,25 @@ import { useState } from "react";
import {
Box,
CalendarClock,
BarChart3,
Clock3,
Droplets,
Eye,
EyeOff,
FlaskConical,
RadioTower,
Sparkles,
type LucideIcon
} from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
import type {
WorkbenchAlert,
WorkbenchAlertDataSource,
WorkbenchUser
} from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu } from "./workbench-top-bar-menus";
import {
compactHeaderTextClassName,
headerControlButtonClassName,
@@ -23,27 +30,31 @@ import {
export type WorkbenchTopBarProps = {
dataTime: string;
modelName: string;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
activeAnalysisArtifact: AnalysisArtifact | null;
alerts: WorkbenchAlert[];
alertDataSource: WorkbenchAlertDataSource;
user: WorkbenchUser;
conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
analysisResultsPanelOpen: boolean;
peripheralSamplePanelOpen: boolean;
devPanelEnabled: boolean;
devPanelOpen: boolean;
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
onToggleAnalysisResultsPanel: () => void;
onTogglePeripheralSamplePanel: () => void;
onToggleDevPanel: () => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
latestAnalysisAvailable: boolean;
analysisSplitAvailable: boolean;
onOpenLatestAnalysis: () => void;
onSetAnalysisView: (view: ArtifactRequestedView) => void;
onCloseAnalysisView: () => void;
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
@@ -51,32 +62,36 @@ export type WorkbenchTopBarProps = {
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user";
export function WorkbenchTopBar({
dataTime,
modelName,
scenarios,
activeScenarioId,
activeAnalysisArtifact,
alerts,
alertDataSource,
user,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
taskTickerVisible,
analysisResultsPanelOpen,
peripheralSamplePanelOpen,
devPanelEnabled,
devPanelOpen,
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
onToggleAnalysisResultsPanel,
onTogglePeripheralSamplePanel,
onToggleDevPanel,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport,
onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
latestAnalysisAvailable,
analysisSplitAvailable,
onOpenLatestAnalysis,
onSetAnalysisView,
onCloseAnalysisView,
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
@@ -84,15 +99,13 @@ export function WorkbenchTopBar({
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario =
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null);
};
return (
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
@@ -102,17 +115,20 @@ export function WorkbenchTopBar({
<Droplets size={18} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
供水管网智能调度系统
<h1 aria-label="供水管网智能调度系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
<span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智能调度系统</span>
</h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
</div>
</div>
<div className="hidden min-w-0 items-center gap-2 text-sm text-slate-700 lg:flex 2xl:gap-4">
<div className="hidden shrink-0 items-center gap-2 text-sm text-slate-700 lg:flex">
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
<HeaderDivider />
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
<div className="hidden items-center gap-2 xl:flex">
<HeaderDivider />
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
</div>
<HeaderDivider />
<TaskTickerToggle
available={taskTickerAvailable}
@@ -126,48 +142,77 @@ export function WorkbenchTopBar({
open={openMenu === "alerts"}
onOpenChange={createMenuOpenChange("alerts")}
alerts={alerts}
sourceLabel={alertDataSource === "mock" ? "工况演示回退" : "后端运行态"}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
<HeaderDivider />
{activeScenario ? (
<ScenarioMenu
open={openMenu === "scenario"}
onOpenChange={createMenuOpenChange("scenario")}
scenarios={scenarios}
activeScenarioId={activeScenario.id}
activeScenarioName={activeScenario.name}
onSelectScenario={onSelectScenario}
onPreviewScenario={onPreviewScenario}
onCompareScenario={onCompareScenario}
onExportScenarioReport={onExportScenarioReport}
/>
{activeAnalysisArtifact ? (
<>
<HeaderDivider />
<AnalysisViewMenu
open={openMenu === "analysis-view"}
onOpenChange={createMenuOpenChange("analysis-view")}
title={activeAnalysisArtifact.title}
subtitle={activeAnalysisArtifact.subtitle}
sourceLabel={getAnalysisOriginLabel(activeAnalysisArtifact.origin)}
splitAvailable={analysisSplitAvailable}
hasSpatialView={activeAnalysisArtifact.mapRelation !== "none"}
onShowResult={() => onSetAnalysisView("focus")}
onShowMapSplit={() => onSetAnalysisView("map_split")}
onShowMapOnly={() => onSetAnalysisView("map_only")}
onClose={onCloseAnalysisView}
/>
</>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="项目分析成果"
aria-pressed={analysisResultsPanelOpen}
title="项目分析成果"
onClick={onToggleAnalysisResultsPanel}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<BarChart3 size={14} aria-hidden="true" />
</span>
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>分析成果</span>
</button>
<button
type="button"
aria-label="外围样板管理"
aria-pressed={peripheralSamplePanelOpen}
title="外围样板管理"
onClick={onTogglePeripheralSamplePanel}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}><RadioTower size={14} aria-hidden="true" /></span>
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围样板</span>
</button>
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
<div className="hidden lg:block">
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
{onCreateTestAnalysis ? (
{latestAnalysisAvailable && !activeAnalysisArtifact ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="生成 Agent 模拟分析成果"
title="生成 Agent 模拟分析成果"
aria-label="打开最新后端分析成果"
title="打开最新后端分析成果"
data-selection-tone="soft"
onClick={onCreateTestAnalysis}
onClick={onOpenLatestAnalysis}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Sparkles size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>测试分析</span>
<span className="hidden text-sm font-semibold xl:inline">最新分析</span>
</button>
</div>
) : null}
@@ -194,6 +239,7 @@ export function WorkbenchTopBar({
open={openMenu === "compact-alerts"}
onOpenChange={createMenuOpenChange("compact-alerts")}
alerts={alerts}
sourceLabel={alertDataSource === "mock" ? "工况演示回退" : "后端运行态"}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onToggleConditionFeed={onToggleConditionFeed}
@@ -215,6 +261,12 @@ export function WorkbenchTopBar({
);
}
function getAnalysisOriginLabel(origin: AnalysisArtifact["origin"]) {
if (origin === "backend") return "后端分析运行";
if (origin === "demo") return "演示成果";
return "Agent 分析";
}
function TaskTickerToggle({
available,
visible,
@@ -306,7 +358,7 @@ function HeaderReadout({
return (
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
<span className="sr-only">{label}</span>
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
{value}
</span>
@@ -146,7 +146,11 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
};
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
return runningWorkflowDefinitions[condition.taskId];
const workflow = runningWorkflowDefinitions[condition.taskId as ScheduledConditionTaskId];
if (!workflow) {
throw new Error("Backend conditions do not use inferred workflow definitions");
}
return workflow;
}
export function getRunningWorkflowState(
@@ -34,14 +34,18 @@ describe("createOperationalScheduledConditions", () => {
const executingWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
condition.subtasks.some((subtask) =>
subtask.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
)
)
);
expect(
executingWorkOrder?.kind === "work_order"
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
? executingWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "execution" && stage.status === "current")?.title
: undefined
).toBe("执行中");
});
@@ -51,13 +55,17 @@ describe("createOperationalScheduledConditions", () => {
const awaitingReplyWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
condition.subtasks.some((subtask) =>
subtask.stages.some((stage) => stage.id === "reply" && stage.status === "current")
)
);
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
expect(
awaitingReplyWorkOrder?.kind === "work_order"
? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title
? awaitingReplyWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "reply" && stage.status === "current")?.title
: undefined
).toBe("待复令");
});
@@ -6,6 +6,7 @@ import type {
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId,
ScheduledWorkOrderSubtask,
ScheduledWorkOrderStatus
} from "../types";
@@ -13,7 +14,7 @@ export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
const HISTORY_WINDOW_MINUTES = 6 * 60;
const GENERATED_SESSION_ID_PREFIX = "scheduled-condition-";
const MODEL_NAME = "DrainFlow Realtime Simulator";
const MODEL_NAME = "SupplyFlow 前端演示模拟器";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId;
@@ -103,9 +104,9 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
{
id: "mock-incident-valve-dispatch",
title: "系统工单:华山路阀门边界确认",
title: "演示工单:华山路阀门边界确认",
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
detail: "该工单由异常事件分析流程生成,用于演示人工确认后提交系统工单并通知管网要素操作。",
detail: "该条目是前端演示回退,用于展示人工确认界面,不代表真实提交或岗位通知。",
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
source: "异常事件分析",
location: "华山路沿线阀门组 VG-11",
@@ -121,7 +122,7 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
},
{
id: "mock-valve-operation-execution",
title: "系统工单:北辰分区阀门开度调整",
title: "演示工单:北辰分区阀门开度调整",
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
@@ -139,7 +140,7 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
},
{
id: "mock-pump-boundary-reply",
title: "系统工单:泵站边界复核复令",
title: "演示工单:泵站边界复核复令",
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
@@ -160,9 +161,9 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
{
id: "huashan-valve-well-service",
title: "人工工单:华山路阀井现场处置",
title: "演示人工工单:华山路阀井现场处置",
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
detail: "该工单由调度员人工创建,来源为现场巡检反馈,不属于周期工况任务。",
detail: "该条目模拟调度员根据现场巡检反馈创建人工工单,不代表真实提交或派发。",
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
source: "现场巡检反馈",
location: "华山路阀井",
@@ -178,9 +179,9 @@ const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
},
{
id: "renmin-road-meter-change",
title: "人工工单:人民路流量计更换",
title: "演示人工工单:人民路流量计更换",
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
detail: "该工单由计量维护计划人工创建,执行前需确认旁通和作业窗口。",
detail: "该条目模拟计量维护计划中的人工工单,执行前需确认旁通和作业窗口。",
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
source: "计量维护计划",
location: "人民路 DN300 支线",
@@ -196,9 +197,9 @@ const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
},
{
id: "beichen-fire-hydrant-repair",
title: "人工工单:北辰消防栓维修",
title: "演示人工工单:北辰消防栓维修",
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
detail: "该工单来自客服缺陷转派,由调度员人工排入当日维修窗口。",
detail: "该条目模拟客服缺陷转派,由调度员排入当日维修窗口,不代表真实派发。",
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
source: "客服缺陷转派",
location: "北辰片区消防栓",
@@ -333,7 +334,6 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
)
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
return {
@@ -350,16 +350,9 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
recommendation: workOrder.recommendation,
durationMinutes: workOrder.durationMinutes,
source: workOrder.source,
location: workOrder.location,
dispatcher: workOrder.dispatcher,
assignee: workOrder.assignee,
priority: workOrder.priority,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
replyRequirements:
currentMinutesOfDay > replyDeadlineMinute
? []
: workOrder.replyRequirements
subtasks: createWorkOrderSubtasks(workOrder, now, scheduledAtDate, phase)
};
})
.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt));
@@ -415,44 +408,112 @@ function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
return "awaiting_reply";
}
function createWorkOrderStages(
function createWorkOrderSubtasks(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
phase: WorkOrderPhase
): ScheduledWorkOrderSubtask[] {
const taskTitles = workOrder.replyRequirements.length > 0
? workOrder.replyRequirements
: [workOrder.title];
const activeIndex = phase === "dispatch"
? 0
: phase === "execution"
? Math.min(1, taskTitles.length - 1)
: taskTitles.length - 1;
const subtaskDuration = Math.max(5, Math.ceil(workOrder.durationMinutes / taskTitles.length));
return taskTitles.map((title, index) => {
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
const status = position === "before"
? "completed"
: position === "after" || phase === "dispatch"
? "pending"
: "running";
const subtaskScheduledAt = new Date(scheduledAtDate.getTime() + index * subtaskDuration * 60_000);
return {
id: `${workOrder.id}-subtask-${index + 1}`,
title,
summary: `完成“${title}”并保留可复核的阶段记录。`,
status,
scheduledAt: formatLocalIsoWithOffset(subtaskScheduledAt),
location: workOrder.location,
assignee: workOrder.assignee,
durationMinutes: subtaskDuration,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderSubtaskStages(
workOrder,
subtaskScheduledAt,
subtaskDuration,
phase,
position
),
replyRequirements: [`回传“${title}”的执行结果`]
};
});
}
function createWorkOrderSubtaskStages(
workOrder: ManualWorkOrderDefinition,
subtaskScheduledAt: Date,
subtaskDurationMinutes: number,
phase: WorkOrderPhase,
position: "before" | "active" | "after"
) {
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
const replyEnd = setMinutesOfDay(
now,
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
const dispatchAt = new Date(
subtaskScheduledAt.getTime() - workOrder.dispatchLeadMinutes * 60_000
);
const executionEnd = new Date(
subtaskScheduledAt.getTime() + subtaskDurationMinutes * 60_000
);
const replyEnd = new Date(executionEnd.getTime() + workOrder.replyWindowMinutes * 60_000);
const allDone = position === "before";
const allPending = position === "after";
return [
{
id: "dispatch" as const,
id: "dispatch",
sequence: 1,
title: "派发",
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "dispatch"
? ("current" as const)
: ("done" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution" as const,
id: "execution",
sequence: 2,
title: "执行中",
status:
phase === "dispatch"
allDone
? ("done" as const)
: allPending || phase === "dispatch"
? ("pending" as const)
: phase === "execution"
? ("current" as const)
: ("done" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
timeLabel: formatClockRange(subtaskScheduledAt, subtaskDurationMinutes),
description: workOrder.summary
},
{
id: "reply" as const,
id: "reply",
sequence: 3,
title: "待复令",
status: phase === "reply" ? ("current" as const) : ("pending" as const),
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "reply"
? ("current" as const)
: ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
@@ -795,7 +856,7 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
},
{
action: "末端压力观察",
target: "华山路末端压力点 P-117",
target: "华山路末端压力点 DEMO-P001",
command: "将该点加入 15 分钟高频复核队列",
verification: "若压力低于 0.18MPa 持续两轮,转人工调度确认"
}
@@ -3,25 +3,25 @@ import type { MapAnnotation } from "../types";
export const mapAnnotations: MapAnnotation[] = [
{
id: "burst",
label: "爆管位置",
label: "演示爆管位置",
coordinate: [121.5215, 30.9084],
kind: "burst"
},
{
id: "dn600",
label: "DN600",
label: "演示 DN600",
coordinate: [121.512, 30.9102],
kind: "pipe-label"
},
{
id: "east-gate",
label: "东直门小区",
label: "演示片区",
coordinate: [121.545, 30.951],
kind: "district"
},
{
id: "impact",
label: "影响范围 约 1.82 km²",
label: "演示影响范围 约 1.82 km²",
coordinate: [121.567, 30.896],
kind: "tooltip"
}
@@ -34,7 +34,7 @@ export const impactAreaPolygon = {
type: "Feature" as const,
properties: {
id: "impact-area",
label: "影响范围",
label: "演示影响范围",
area: "1.82 km²"
},
geometry: {
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchElementGisProperties,
fetchSourceGisFeatureProperties,
type GisAttributeGroup
} from "../api/gis-properties-client";
import { isSourceGisSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
type GisPropertiesState = {
status: "idle" | "loading" | "ready" | "error";
groups: GisAttributeGroup[];
};
const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
export function useElementGisProperties(
feature: DetailFeature | null,
projectId: string | null,
getAccessToken?: AccessTokenProvider
) {
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
useEffect(() => {
if (!feature) {
setState(INITIAL_STATE);
return;
}
if (feature.layer === "water_quality_monitoring_points") {
setState({ status: "ready", groups: [] });
return;
}
const controller = new AbortController();
setState({ status: "loading", groups: [] });
const sourceLayerKey = feature.properties.source_layer_key;
const rawSourceFid = feature.properties.source_fid;
const sourceFid = typeof rawSourceFid === "number"
? rawSourceFid
: Number(rawSourceFid);
const request = isSourceGisSourceId(feature.layer)
&& typeof sourceLayerKey === "string"
&& Number.isSafeInteger(sourceFid)
&& sourceFid >= 0
? fetchSourceGisFeatureProperties(
sourceLayerKey,
sourceFid,
projectId,
getAccessToken,
controller.signal
)
: fetchElementGisProperties(
feature.layer,
feature.id,
projectId,
getAccessToken,
controller.signal
);
void request.then((groups) => {
if (!controller.signal.aborted) setState({ status: "ready", groups });
}).catch(() => {
if (!controller.signal.aborted) setState({ status: "error", groups: [] });
});
return () => controller.abort();
}, [feature, getAccessToken, projectId]);
return state;
}
@@ -33,14 +33,13 @@ describe("map feature interaction state", () => {
});
it("uses transparent hit layers for pointer interactions", () => {
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual(expect.arrayContaining([
"pipes-hit",
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit"
]);
"scada-pressure-hit",
"scada-flow-hit",
"water_quality_monitoring_points-hit",
"fx_20200423_pipes-hit"
]));
});
});
@@ -7,6 +7,7 @@ import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import {
FrontendActionExecutor,
agentBootstrapKey,
@@ -62,7 +63,18 @@ export function useWorkbenchAgent({
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
const getProjectId = useCallback(() => {
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
({ project }) => project.project_id,
(error) => {
projectIdPromiseRef.current = null;
throw error;
}
);
return projectIdPromiseRef.current;
}, [getAccessToken]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId }));
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
@@ -82,6 +94,7 @@ export function useWorkbenchAgent({
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [promptDraft, setPromptDraft] = useState("");
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
@@ -605,6 +618,7 @@ export function useWorkbenchAgent({
setPermissionOverrides({});
setQuestionOverrides({});
setStreamRenderState({});
setPromptDraft("");
chat.setMessages([]);
},
[chat, clearSessionId]
@@ -821,7 +835,9 @@ export function useWorkbenchAgent({
statusLabel,
streamRenderState,
selectedModel,
promptDraft,
setApprovalMode,
setPromptDraft,
setSelectedModel,
stopPrompt,
streaming,
+144 -28
View File
@@ -1,6 +1,6 @@
import "maplibre-gl/dist/maplibre-gl.css";
import type { FeatureCollection, Point } from "geojson";
import type { FeatureCollection, Geometry, Point } from "geojson";
import maplibregl, {
type GeoJSONSource,
type Map as MapLibreMap,
@@ -10,14 +10,14 @@ import maplibregl, {
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
simulationAnnotationLayers,
simulationSources
} from "../map/annotation-layers";
import { fitNetworkBounds } from "../map/camera";
import { fitNetworkBounds, type NetworkViewParams } from "../map/camera";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
@@ -66,6 +66,9 @@ type UseWorkbenchMapOptions = {
onClearSelection: () => void;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: AccessTokenProvider;
};
@@ -75,7 +78,9 @@ type UseWorkbenchMapResult = {
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void;
};
@@ -105,10 +110,16 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
};
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
const SCADA_REFRESH_INTERVAL_MS = 15_000;
const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
type: "FeatureCollection",
features: []
};
export function useWorkbenchMap({
containerRef,
@@ -117,16 +128,30 @@ export function useWorkbenchMap({
onClearSelection,
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const [scadaObservedAt, setScadaObservedAt] = useState<string | null>(null);
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
GeoServerWaterNetworkSourceId[]
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
const [sourceFeatureCounts, setSourceFeatureCounts] = useState<
Partial<Record<WaterNetworkSourceId, number>>
>({});
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
const geoServerLayerProbeInFlightRef = useRef(false);
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
@@ -172,6 +197,9 @@ export function useWorkbenchMap({
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
@@ -224,12 +252,8 @@ export function useWorkbenchMap({
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const scadaLoadPromise = fetchScadaDeviceCollection(
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = scadaLoadPromise;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
@@ -254,6 +278,12 @@ export function useWorkbenchMap({
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(
map,
simulationOverlayRef.current,
allowDemoSimulationRef.current
);
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers =
@@ -305,27 +335,47 @@ export function useWorkbenchMap({
: "SCADA 图标不可用,已使用圆点图层降级显示。"
);
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
fittedNetworkViewRef.current = networkViewKey(networkViewRef.current);
});
void scadaLoadPromise.then((collection) => {
scadaCollectionRef.current = collection;
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
"online",
`已通过API加载 ${collection.features.length} 个SCADA设备。`
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(
getAccessToken,
scadaAbortController.signal
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current);
try {
const snapshot = await snapshotPromise;
if (scadaAbortController.signal.aborted) return;
scadaCollectionRef.current = snapshot.collection;
setScadaObservedAt(snapshot.observedAt);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(snapshot.collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
snapshot.telemetryStatus === "ready" ? "online" : "degraded",
getScadaStatusMessage(snapshot)
);
} catch (error) {
if (scadaAbortController.signal.aborted) return;
setScadaObservedAt(null);
updateSourceStatus(
setSourceStatuses,
"scada-api",
scadaCollectionRef.current.features.length > 0 ? "degraded" : "offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
} finally {
if (!scadaAbortController.signal.aborted) {
scadaRefreshTimeoutId = window.setTimeout(refreshScada, SCADA_REFRESH_INTERVAL_MS);
}
}
};
void refreshScada();
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
@@ -375,6 +425,9 @@ export function useWorkbenchMap({
}
resizeObserver.disconnect();
scadaAbortController.abort();
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -387,6 +440,15 @@ export function useWorkbenchMap({
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !networkView) return;
const key = networkViewKey(networkView);
if (fittedNetworkViewRef.current === key) return;
fitNetworkBounds(map, networkView);
fittedNetworkViewRef.current = key;
}, [mapReady, networkView]);
useMapInteractions({
availableSourceIds: availableGeoServerSourceIds,
mapRef,
@@ -401,10 +463,16 @@ export function useWorkbenchMap({
visible: impactVisible
});
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
}, [allowDemoSimulation, mapReady, simulationOverlay]);
function fitToNetworkBounds() {
const map = mapRef.current;
if (map) {
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
}
}
@@ -418,12 +486,60 @@ export function useWorkbenchMap({
sourceId === "scada" ||
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
),
sourceFeatureCounts,
getScadaFeatures,
scadaObservedAt,
refreshGeoServerLayers,
fitNetworkBounds: fitToNetworkBounds
};
}
function areFeatureCountsEqual(
current: Partial<Record<WaterNetworkSourceId, number>>,
next: Partial<Record<WaterNetworkSourceId, number>>
) {
const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next);
return currentEntries.length === nextEntries.length
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
}
function networkViewKey(view: NetworkViewParams) {
return view.bbox3857.join(",");
}
function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSnapshot>>) {
const deviceSummary = `已通过API加载 ${snapshot.collection.features.length} 个SCADA设备`;
if (snapshot.telemetryStatus === "ready") {
return `${deviceSummary},其中 ${snapshot.observedDeviceCount} 个带有最近15分钟监测值。`;
}
if (snapshot.telemetryStatus === "unavailable") {
return `${deviceSummary};实时监测值暂不可用,保留设备元数据。`;
}
return `${deviceSummary};最近15分钟没有监测值,保留设备元数据。`;
}
function syncSimulationSourceData(
map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null,
allowDemoSimulation: boolean
) {
const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
);
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.impactArea.data
);
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.annotations.data
);
}
function syncDataDependentGeoServerLayers(
map: MapLibreMap,
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchProjectPresentation } from "../api/project-client";
import { resolveWorkbenchUser } from "./use-workbench-project-presentation";
const fallbackUser = {
name: "调度员",
role: "供水运行调度中心"
};
function presentation(
overrides: Partial<WorkbenchProjectPresentation> = {}
): WorkbenchProjectPresentation {
return {
projectId: "project-1",
projectName: "北区供水项目",
username: "north.operator",
projectRole: "member",
systemRole: "user",
isSystemAdmin: false,
networkView: null,
metadataAvailable: true,
accessContextAvailable: true,
...overrides
};
}
describe("workbench project presentation", () => {
it("uses backend identity and a readable project role", () => {
expect(resolveWorkbenchUser(fallbackUser, presentation())).toEqual({
name: "north.operator",
role: "北区供水项目 · 项目成员"
});
});
it("retains the existing user when project context is unavailable", () => {
expect(resolveWorkbenchUser(fallbackUser, null)).toBe(fallbackUser);
});
it("can enrich the authenticated fallback name with project metadata", () => {
expect(resolveWorkbenchUser(fallbackUser, presentation({
username: null,
projectRole: "viewer"
}))).toEqual({
name: "调度员",
role: "北区供水项目 · 项目访客"
});
});
});
@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchProjectPresentation,
type WorkbenchProjectPresentation
} from "../api/project-client";
import type { WorkbenchUser } from "../types";
const PROJECT_ROLE_LABELS: Record<string, string> = {
owner: "项目负责人",
member: "项目成员",
viewer: "项目访客"
};
export function useWorkbenchProjectPresentation(
fallbackUser: WorkbenchUser,
getAccessToken?: AccessTokenProvider
) {
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
.then((value) => {
if (!controller.signal.aborted) setPresentation(value);
})
.catch(() => {
if (!controller.signal.aborted) setPresentation(null);
});
return () => controller.abort();
}, [getAccessToken]);
const user = useMemo(
() => resolveWorkbenchUser(fallbackUser, presentation),
[fallbackUser, presentation]
);
return {
user,
projectId: presentation?.projectId ?? null,
networkView: presentation?.networkView ?? null,
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
? "backend" as const
: "fallback" as const
};
}
export function resolveWorkbenchUser(
fallbackUser: WorkbenchUser,
presentation: WorkbenchProjectPresentation | null
): WorkbenchUser {
if (!presentation) return fallbackUser;
const role = presentation.projectRole
? PROJECT_ROLE_LABELS[presentation.projectRole] ?? `项目角色 ${presentation.projectRole}`
: null;
const roleParts = [
presentation.projectName,
role,
presentation.isSystemAdmin ? "系统管理员" : null
].filter((part): part is string => Boolean(part));
return {
name: presentation.username ?? fallbackUser.name,
role: roleParts.length > 0 ? roleParts.join(" · ") : fallbackUser.role
};
}
@@ -42,9 +42,6 @@ export function useWorkbenchResponsiveLayout({
const handleViewportChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleViewportChange();
@@ -58,6 +55,17 @@ export function useWorkbenchResponsiveLayout({
};
}, []);
useEffect(() => {
if (!isLargeScreen || mobileSheet === null) {
return;
}
if (mobileSheet === "agent") {
expandAgentPanel();
}
setMobileSheet(null);
}, [expandAgentPanel, isLargeScreen, mobileSheet]);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
useEffect(() => {
@@ -1,15 +1,375 @@
import { describe, expect, it } from "vitest";
import {
formatHeaderDataTime,
getMsUntilNextHeaderDataTimeTick
getMsUntilNextHeaderDataTimeTick,
resolveWorkbenchOperationalDate,
resolveWorkbenchRuntimeData
} from "./use-workbench-runtime-data";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
const SEPTEMBER_17 = new Date("2026-09-17T02:30:00Z");
const SEPTEMBER_18 = new Date("2026-09-18T02:30:00Z");
const mockCondition: ScheduledConditionRecord = {
id: "mock-condition",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "scheduled-condition-mock",
title: "SCADA 数据诊断",
summary: "mock",
status: "error",
riskLevel: "critical",
scheduledAt: "2026-09-17T10:00:00+08:00",
updatedAt: Date.parse("2026-09-17T10:00:00+08:00")
};
const backendCondition: ScheduledConditionRecord = {
...mockCondition,
id: "schedule-run-1",
taskId: "backend-schedule",
sessionId: "scheduled-condition-schedule-run-1",
title: "演示 合成低压调查",
modelName: "TJWater scheduler"
};
const mockWorkOrder: ScheduledWorkOrderItem = {
id: "mock-work-order",
kind: "work_order",
code: "WO-MOCK",
scheduledAt: "2026-09-17T11:00:00+08:00",
title: "演示工单",
summary: "mock",
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-17T10:00:00+08:00"),
detail: "mock",
durationMinutes: 30,
source: "演示",
dispatcher: "调度中心",
priority: "normal",
subtasks: [{
id: "mock-subtask-1",
title: "现场复核",
summary: "复核设备状态并回传。",
status: "pending",
scheduledAt: "2026-09-17T11:00:00+08:00",
location: "V-1",
assignee: "现场班组",
durationMinutes: 30,
replyWindowMinutes: 15,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "current",
owner: "调度中心",
timeLabel: "10:45",
description: "派发现场复核任务。"
}],
replyRequirements: ["回传设备状态"]
}]
};
describe("workbench runtime data", () => {
it("snaps the header time to the current five-minute interval", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
});
it("preserves the backend observation minute", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31), false)).toBe("2026-07-22 15:43");
});
it("schedules the next header refresh on the next interval boundary", () => {
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
});
it("keeps mock conditions and shows no work orders when backend storage is not configured", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "not_configured",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workbenchAlerts).toHaveLength(1);
expect(resolved.scenarioDataSource).toBe("mock");
expect(resolved.artifactDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("none");
});
it("keeps mock mode separate from the selected backend history day", () => {
const backendAlert = {
id: "scheduled-condition-alert-schedule-run-1",
title: "演示 测试待复核",
description: "运行失败",
time: "10:25"
};
const backendSnapshot = {
availability: "ready" as const,
source: "scheduled_tasks" as const,
scenarioAvailability: "not_configured" as const,
artifactAvailability: "not_configured" as const,
scheduleAvailability: "not_configured" as const,
workOrderAvailability: "not_configured" as const,
alertAvailability: "not_configured" as const,
generatedAt: "2026-09-17T02:30:00Z",
conditions: [backendCondition],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
};
const resolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
operationalDate: SEPTEMBER_17,
runtimeMode: "history"
});
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workbenchAlerts).not.toEqual([backendAlert]);
expect(resolved.workbenchAlerts[0]?.id).toBe("scheduled-condition-alert-mock-condition");
expect(resolved.alertDataSource).toBe("mock");
expect(resolved.scenarioDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("none");
const mockResolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
operationalDate: SEPTEMBER_17,
runtimeMode: "demo"
});
expect(mockResolved.runtimeDataSource).toBe("mock");
expect(mockResolved.scheduledConditions).toEqual([mockCondition]);
expect(mockResolved.workOrderDataSource).toBe("none");
});
it("anchors history mode to the latest day that contains backend work orders", () => {
const olderWorkOrder = {
...mockWorkOrder,
id: "older-backend-work-order",
scheduledAt: "2026-08-03T10:05:00+08:00"
};
const latestWorkOrder = {
...mockWorkOrder,
id: "latest-backend-work-order",
scheduledAt: "2026-08-17T17:40:00+08:00"
};
const futureWorkOrder = {
...mockWorkOrder,
id: "future-backend-work-order",
scheduledAt: "2026-09-20T10:00:00+08:00"
};
const now = new Date("2026-09-18T02:30:00Z");
const operationalDate = resolveWorkbenchOperationalDate({
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "ready",
alertAvailability: "ready",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [backendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
}, { runtimeMode: "history", now });
expect(operationalDate.toISOString()).toBe("2026-08-17T09:40:00.000Z");
expect(resolveWorkbenchOperationalDate({
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "ready",
alertAvailability: "ready",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
}, { runtimeMode: "demo", now }).toISOString()).toBe("2026-08-17T09:40:00.000Z");
});
it("keeps current-day mock conditions when backend schedules are configured", () => {
const currentMockCondition = {
...mockCondition,
id: "mock-current-day",
scheduledAt: "2026-09-18T10:00:00+08:00"
};
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [backendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_18 });
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
});
it("ignores current-day backend conditions and keeps demo conditions", () => {
const currentMockCondition = {
...mockCondition,
id: "mock-current-day",
scheduledAt: "2026-09-18T10:00:00+08:00"
};
const currentBackendCondition = {
...backendCondition,
id: "backend-current-day",
scheduledAt: "2026-09-18T10:05:00+08:00"
};
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [currentBackendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_18 });
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
});
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "ready",
alertAvailability: "not_configured",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend");
});
it("uses a provisioned alert list while other sections keep their mock fallback", () => {
const backendAlert = {
id: "backend-alert-alarm-1",
title: "泵站通信告警",
description: "来自正式告警同步适配器。",
severity: "warning" as const,
time: "10:29"
};
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "ready",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
});
it("does not invent demo alerts when the backend alert partition is empty", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "ready",
workOrderAvailability: "not_configured",
alertAvailability: "ready",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.workbenchAlerts).toEqual([]);
});
it("uses valid backend records retained by a degraded partition", () => {
const backendWorkOrder = { ...mockWorkOrder, id: "backend-work-order", code: "WO-BACKEND" };
const backendAlert = {
id: "backend-alert-1",
title: "后端任务告警",
description: "合法记录与另一条坏记录同时存在。",
severity: "warning" as const,
time: "09-18 10:29"
};
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "degraded",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "degraded",
alertAvailability: "degraded",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [backendWorkOrder]
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend");
});
});
@@ -1,23 +1,60 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
fetchWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "../api/workbench-runtime-client";
import { fetchRealtimePipeFlows } from "../api/realtime-flow-client";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "../data/scheduled-conditions";
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
import type { ScheduledConditionItem } from "../types";
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
const BACKEND_RUNTIME_REFRESH_INTERVAL_MS = 15_000;
export function formatHeaderDataTime(date: Date) {
const snappedMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
export function resolveWorkbenchOperationalDate(
backendSnapshot: WorkbenchRuntimeSnapshot | null,
options: {
now?: Date;
runtimeMode?: "auto" | "demo" | "history" | "backend";
} = {}
) {
const now = options.now ?? new Date();
if (
!["demo", "history"].includes(options.runtimeMode ?? "auto")
|| !backendSnapshot
) return now;
const timestamps = backendSnapshot.workOrders
.map((workOrder) => Date.parse(workOrder.scheduledAt))
.filter(Number.isFinite);
const historicalTimestamps = timestamps.filter((timestamp) => timestamp <= now.getTime());
const latestTimestamp = historicalTimestamps.length > 0
? Math.max(...historicalTimestamps)
: timestamps.length > 0
? Math.min(...timestamps)
: null;
return latestTimestamp === null ? now : new Date(latestTimestamp);
}
export function formatHeaderDataTime(date: Date, snapToStep = true) {
const minutes = snapToStep
? Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES
: date.getMinutes();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
return `${year}-${month}-${day} ${hours}:${formattedMinutes}`;
}
export function getMsUntilNextHeaderDataTimeTick(date: Date) {
@@ -30,9 +67,74 @@ export function getMsUntilNextHeaderDataTimeTick(date: Date) {
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function useWorkbenchRuntimeData() {
export function resolveWorkbenchRuntimeData(
mockConditions: ScheduledConditionItem[],
backendSnapshot: WorkbenchRuntimeSnapshot | null,
options: {
operationalDate?: Date;
runtimeMode?: "auto" | "demo" | "history" | "backend";
} = {}
) {
const operationalDate = options.operationalDate ?? new Date();
const fallbackConditions = mockConditions.filter(
(condition) => condition.kind === "condition"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
);
const backendWorkOrdersConfigured = Boolean(
backendSnapshot && backendSnapshot.workOrderAvailability !== "not_configured"
);
const resolvedWorkOrders = backendWorkOrdersConfigured ? backendWorkOrders : [];
const backendAlertsConfigured = Boolean(
backendSnapshot
&& backendSnapshot.alertAvailability !== "not_configured"
);
const useBackendScenarios = Boolean(
backendSnapshot
&& backendSnapshot.scenarioAvailability !== "not_configured"
&& backendSnapshot.scenarios.length > 0
);
const useBackendArtifact = Boolean(
backendSnapshot
&& backendSnapshot.artifactAvailability !== "not_configured"
&& backendSnapshot.latestAnalysisArtifact
);
return {
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions),
alertDataSource: backendAlertsConfigured ? "backend" as const : "mock" as const,
runtimeDataSource: "mock" as const,
scenarios: useBackendScenarios ? backendSnapshot!.scenarios : WORKBENCH_SCENARIOS,
scenarioDataSource: useBackendScenarios ? "analysis_runs" as const : "mock" as const,
latestAnalysisArtifact: useBackendArtifact
? backendSnapshot!.latestAnalysisArtifact
: null,
simulationOverlay: useBackendArtifact ? backendSnapshot!.simulationOverlay : null,
artifactDataSource: useBackendArtifact ? "analysis_runs" as const : "mock" as const,
workOrderDataSource: backendWorkOrdersConfigured ? "backend" as const : "none" as const
};
}
function isSameLocalOperationalDate(value: string, operationalDate: Date) {
const candidate = new Date(value);
if (Number.isNaN(candidate.getTime()) || Number.isNaN(operationalDate.getTime())) return false;
return candidate.getFullYear() === operationalDate.getFullYear()
&& candidate.getMonth() === operationalDate.getMonth()
&& candidate.getDate() === operationalDate.getDate();
}
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
ReturnType<typeof fetchRealtimePipeFlows>
> | null>(null);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
useEffect(() => {
@@ -50,7 +152,10 @@ export function useWorkbenchRuntimeData() {
useEffect(() => {
const refreshScheduledConditions = () => {
setScheduledConditions(createOperationalScheduledConditions(new Date()));
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
setMockConditions(createOperationalScheduledConditions(operationalDate));
setScheduledConditionsLoading(false);
};
@@ -61,11 +166,114 @@ export function useWorkbenchRuntimeData() {
);
return () => window.clearInterval(intervalId);
}, []);
}, [backendSnapshot]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshBackendSnapshot = async () => {
try {
const snapshot = await fetchWorkbenchRuntimeSnapshot(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setBackendSnapshot(snapshot);
}
} catch {
if (!controller.signal.aborted) setBackendSnapshot(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshBackendSnapshot,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshBackendSnapshot();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshRealtimePipeFlows = async () => {
try {
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
}
} catch {
if (!controller.signal.aborted) setRealtimePipeFlows(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshRealtimePipeFlows,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshRealtimePipeFlows();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
const resolvedRuntimeData = resolveWorkbenchRuntimeData(mockConditions, backendSnapshot, {
operationalDate,
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
const backendDataTime = realtimePipeFlows?.observedAt;
const backendRuntimeDataTime = backendSnapshot?.generatedAt;
const demoMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "demo";
const historyMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "history";
const backendRuntimeActive = resolvedRuntimeData.runtimeDataSource !== "mock";
const backendWorkOrdersActive = resolvedRuntimeData.workOrderDataSource === "backend";
const backendHistoryActive = historyMode && (backendRuntimeActive || backendWorkOrdersActive);
return {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading
headerDataTime: backendHistoryActive
? `历史 ${formatHeaderDataTime(operationalDate, false)}`
: demoMode || historyMode
? `演示 ${formatHeaderDataTime(operationalDate, false)}`
: backendDataTime
? formatHeaderDataTime(new Date(backendDataTime), false)
: backendRuntimeActive && backendRuntimeDataTime
? formatHeaderDataTime(new Date(backendRuntimeDataTime), false)
: headerDataTime === INITIAL_HEADER_DATA_TIME
? headerDataTime
: `演示 ${headerDataTime}`,
headerDataSource: backendHistoryActive
? "history" as const
: demoMode || historyMode
? "demo" as const
: backendDataTime
? "realtime" as const
: backendRuntimeActive
? "backend" as const
: "mock" as const,
operationalDate: formatLocalDateKey(operationalDate),
operationalCursorTime: operationalDate.toISOString(),
scheduledConditionsLoading,
realtimePipeFlows,
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
...resolvedRuntimeData
};
}
function formatLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -0,0 +1,66 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchScenarioAnalysis } from "./use-workbench-scenario-analysis";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
function analysis(runId: string) {
return {
run_id: runId,
name: "历史爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
}
describe("selected workbench scenario analysis", () => {
afterEach(() => vi.unstubAllGlobals());
it("loads geometry for the selected backend run", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(analysis(runId)), { status: 200 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("backend"));
expect(result.current.projection?.runId).toBe(runId);
expect(result.current.projection?.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
unmount();
});
it("falls back independently when the selected run cannot be read", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "unavailable" }), { status: 503 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("fallback"));
expect(result.current.projection).toBeNull();
unmount();
});
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchAnalysisRun,
type WorkbenchAnalysisProjection
} from "../api/workbench-runtime-client";
const SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS = 15_000;
type ScenarioAnalysisState = {
runId: string;
projection: WorkbenchAnalysisProjection | null;
failed: boolean;
};
export type WorkbenchScenarioAnalysis = {
projection: WorkbenchAnalysisProjection | null;
status: "disabled" | "loading" | "backend" | "fallback";
};
export function useWorkbenchScenarioAnalysis(
runId: string | null,
getAccessToken?: AccessTokenProvider
): WorkbenchScenarioAnalysis {
const [state, setState] = useState<ScenarioAnalysisState | null>(null);
useEffect(() => {
if (!runId) {
setState(null);
return;
}
const controller = new AbortController();
let timeoutId: number | undefined;
setState((current) => current?.runId === runId
? current
: { runId, projection: null, failed: false });
const refresh = async () => {
try {
const projection = await fetchWorkbenchAnalysisRun(
runId,
getAccessToken,
controller.signal
);
if (!controller.signal.aborted) {
setState({ runId, projection, failed: false });
}
} catch {
if (!controller.signal.aborted) {
setState({ runId, projection: null, failed: true });
}
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(refresh, SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS);
}
}
};
void refresh();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken, runId]);
if (!runId) return { projection: null, status: "disabled" };
if (!state || state.runId !== runId) return { projection: null, status: "loading" };
if (state.failed) return { projection: null, status: "fallback" };
if (state.projection) return { projection: state.projection, status: "backend" };
return { projection: null, status: "loading" };
}
@@ -4,6 +4,7 @@ export const WORKBENCH_LAYOUT = {
persistentConditionMinWidth: 1280,
wideMinWidth: 1536,
collapsedAgentWidth: 0,
collapsedAgentRailWidth: 72,
maxAgentWidth: 720,
agentPanelLeftInset: 12,
conditionPanelRightInset: 64,
@@ -164,6 +165,7 @@ export function getWorkbenchLayoutCssVariables() {
return {
"--workbench-agent-width": `${WORKBENCH_LAYOUT.desktop.agentWidth}px`,
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
"--workbench-agent-rail-width": `${WORKBENCH_LAYOUT.collapsedAgentRailWidth}px`,
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
+152 -89
View File
@@ -33,6 +33,8 @@ import {
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
@@ -62,13 +64,17 @@ import {
createInitialWorkspaceContext,
reduceWorkspaceContext
} from "./workspace/workspace-context";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import {
formatHeaderDataTime,
useWorkbenchRuntimeData
} from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
useWorkbenchMeasurement,
@@ -87,9 +93,9 @@ import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
applyBaseLayerVisibility,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map/map-control-config";
import { waterNetworkToolbarItems } from "./map/toolbar-config";
@@ -100,10 +106,7 @@ import type {
WorkbenchAlert,
WorkbenchUser
} from "./types";
import {
createScheduledConditionAlerts,
getScheduledConditionIdFromAlertId
} from "./utils/scheduled-condition-alerts";
import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
@@ -119,6 +122,7 @@ export function MapWorkbenchPage({
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
@@ -126,6 +130,8 @@ export function MapWorkbenchPage({
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [analysisResultsPanelOpen, setAnalysisResultsPanelOpen] = useState(false);
const [peripheralSamplePanelOpen, setPeripheralSamplePanelOpen] = useState(false);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
@@ -135,15 +141,26 @@ export function MapWorkbenchPage({
hasMapboxToken ? "mapbox-light" : "none"
);
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
const [activeDrawToolId, setActiveDrawToolId] = useState<WorkbenchDrawMode | null>(null);
const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
useWorkbenchRuntimeData();
const {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading,
workbenchAlerts,
runtimeDataSource,
alertDataSource,
latestAnalysisArtifact,
workOrderDataSource,
realtimePipeFlows,
flowDataSource,
operationalDate,
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken);
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -204,13 +221,29 @@ export function MapWorkbenchPage({
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation = artifactWorkspace.activeArtifact === null
|| artifactWorkspace.activeArtifact.origin === "demo";
const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some((block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
)
);
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
const [timelineVisible, setTimelineVisible] = useState(true);
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
reduceWorkspaceContext,
undefined,
() => createInitialWorkspaceContext()
() => createInitialWorkspaceContext(new Date(operationalCursorTime))
);
useEffect(() => {
if (workspaceContext.operational.date === operationalDate) return;
dispatchWorkspaceContext({
type: "return-operational-now",
now: operationalCursorTime
});
}, [operationalCursorTime, operationalDate, workspaceContext.operational.date]);
const operationalEvents = useMemo(
() => createOperationalEvents(scheduledConditions),
[scheduledConditions]
@@ -243,6 +276,13 @@ export function MapWorkbenchPage({
});
}, [artifactWorkspace.activeArtifact]);
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView });
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
}
@@ -268,8 +308,10 @@ export function MapWorkbenchPage({
mapError,
sourceStatuses,
availableSourceIds,
sourceFeatureCounts,
fitNetworkBounds,
getScadaFeatures,
scadaObservedAt,
refreshGeoServerLayers
} = useWorkbenchMap({
containerRef: mapContainerRef,
@@ -278,8 +320,15 @@ export function MapWorkbenchPage({
onClearSelection: handleClearSelection,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature,
simulationOverlay: activeSpatialOverlay,
allowDemoSimulation,
networkView: projectPresentation.networkView,
getAccessToken
});
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE)
&& scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
@@ -291,24 +340,29 @@ export function MapWorkbenchPage({
availableSourceIds
});
useEffect(() => {
if (!mapReady) return;
if (realtimePipeFlows) {
void mapController.replacePipeFlowCalculations(realtimePipeFlows.records);
return;
}
void mapController.clearPipeFlowCalculations();
}, [mapController, mapReady, realtimePipeFlows]);
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
);
const conditionAlerts = useMemo(
() => createScheduledConditionAlerts(scheduledConditions),
[scheduledConditions]
);
const runningTickerConditions = useMemo(
const runningTickerConditions = useMemo<ScheduledConditionRecord[]>(
() =>
scheduledConditions.filter(
(condition): condition is ScheduledConditionRecord =>
condition.kind === "condition" && condition.status === "running"
),
scheduledConditions.reduce<ScheduledConditionRecord[]>((runningConditions, condition) => {
if (condition.kind === "condition" && condition.status === "running") {
runningConditions.push(condition);
}
return runningConditions;
}, []),
[scheduledConditions]
);
const workbenchAlerts = conditionAlerts;
const drawing = useWorkbenchDrawing({
mapRef,
mapReady,
@@ -327,6 +381,7 @@ export function MapWorkbenchPage({
!artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& detailFeature === null
&& taskTickerVisible
&& taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
@@ -424,10 +479,10 @@ export function MapWorkbenchPage({
}, [mapController]);
const handleReturnOperationalNow = useCallback(() => {
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
dispatchWorkspaceContext({ type: "return-operational-now", now: operationalCursorTime });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
}, [mapController, operationalCursorTime]);
const toolbarItems = useMemo<MapToolbarItem[]>(
() =>
@@ -441,8 +496,12 @@ export function MapWorkbenchPage({
);
const layerControlItems = useMemo(
() => createLayerControlItems(layerVisibility, availableSourceIds),
[availableSourceIds, layerVisibility]
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts),
[availableSourceIds, layerVisibility, sourceFeatureCounts]
);
const visibleLegendItems = useMemo(
() => createVisibleMapLegendItems(layerControlItems),
[layerControlItems]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
const map = mapRef.current;
@@ -545,48 +604,9 @@ export function MapWorkbenchPage({
});
}
function handleSelectScenario(scenarioId: string) {
const scenario = WORKBENCH_SCENARIOS.find((item) => item.id === scenarioId);
setActiveScenarioId(scenarioId);
showMapNotice({
tone: "success",
title: "方案已切换",
message: `当前方案:${scenario?.name ?? scenarioId}。地图模拟结果暂未自动切换。`
});
}
function handlePreviewScenario() {
setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true }));
const map = mapRef.current;
if (map && mapReady) {
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
map.setLayoutProperty(layerId, "visibility", "visible");
});
}
showMapNotice({
tone: "info",
title: "预览影响范围",
message: "已打开模拟结果图层,可在右侧图层面板继续调整显示。"
});
}
function handleCompareScenario() {
showMapNotice({
tone: "info",
title: "方案比较",
message: "候选方案比较将汇总影响范围、阀门操作与保供风险。"
});
}
function handleExportScenarioReport() {
showMapNotice({
tone: "success",
title: "场景报告",
message: "已生成当前场景报告任务,可继续导出地图截图或配置。"
});
function handleOpenLatestAnalysis() {
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
artifactWorkspace.openArtifact(latestAnalysisArtifact);
}
async function handleAnalyzeAlertsWithAgent() {
@@ -610,7 +630,8 @@ export function MapWorkbenchPage({
await agent.submitPrompt(
createAlertQueueConversationPrompt({
alerts: workbenchAlerts,
conditions: scheduledConditions
conditions: scheduledConditions,
dataSource: alertDataSource
})
);
@@ -629,7 +650,7 @@ export function MapWorkbenchPage({
}
function handleSelectAlert(alert: WorkbenchAlert) {
const conditionId = getScheduledConditionIdFromAlertId(alert.id);
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
if (conditionId) {
const condition = scheduledConditions.find((item) => item.id === conditionId);
if (!condition) {
@@ -858,7 +879,7 @@ export function MapWorkbenchPage({
canDeleteSelectedDrawing: drawing.canDeleteSelected,
hasDrawingFeatures: drawing.hasFeatures,
layerItems: layerControlItems,
legendItems: MAP_LEGEND_ITEMS,
legendItems: visibleLegendItems,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode: setActiveMeasureModeId,
@@ -929,6 +950,7 @@ export function MapWorkbenchPage({
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -936,6 +958,7 @@ export function MapWorkbenchPage({
onDeleteHistorySession: handleDeleteAgentHistorySession,
onSelectModel: agent.setSelectedModel,
onApprovalModeChange: agent.setApprovalMode,
onPromptChange: agent.setPromptDraft,
onSubmitPrompt: agent.submitPrompt,
onStopPrompt: agent.stopPrompt,
onReplyPermission: agent.replyPermission,
@@ -956,29 +979,35 @@ export function MapWorkbenchPage({
}
>
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="AquaDispatch v1.0"
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
dataTime={displayedDataTime}
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label
|| agent.selectedModel
|| "Agent 后端连接中"}
activeAnalysisArtifact={artifactWorkspace.activeArtifact}
alerts={workbenchAlerts}
user={user}
alertDataSource={alertDataSource}
user={projectPresentation.user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
analysisResultsPanelOpen={analysisResultsPanelOpen}
peripheralSamplePanelOpen={peripheralSamplePanelOpen}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }}
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
latestAnalysisAvailable={latestAnalysisAvailable}
analysisSplitAvailable={artifactWorkspace.mapSplitAvailable}
onOpenLatestAnalysis={handleOpenLatestAnalysis}
onSetAnalysisView={artifactWorkspace.setView}
onCloseAnalysisView={artifactWorkspace.destroyArtifact}
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
@@ -986,6 +1015,22 @@ export function MapWorkbenchPage({
onLogout={onLogout}
/>
<AnalysisResultsPanel
open={analysisResultsPanelOpen}
onClose={() => setAnalysisResultsPanelOpen(false)}
getAccessToken={getAccessToken}
onOpenArtifact={(artifact) => {
artifactWorkspace.openArtifact(artifact);
setAnalysisResultsPanelOpen(false);
}}
/>
<PeripheralSamplePanel
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
/>
<WorkbenchMainFrame
activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact}
@@ -1049,12 +1094,16 @@ export function MapWorkbenchPage({
)}
mapOverlay={(
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
</>
) : null}
<div
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{
@@ -1092,6 +1141,8 @@ export function MapWorkbenchPage({
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
@@ -1112,6 +1163,7 @@ export function MapWorkbenchPage({
commands={mapController}
controllerState={mapControllerState}
detailFeature={detailFeature}
flowDataSource={flowDataSource}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
@@ -1146,7 +1198,11 @@ export function MapWorkbenchPage({
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker className="w-full max-w-full" conditions={runningTickerConditions} />
<AgentTaskTicker
className="w-full max-w-full"
conditions={runningTickerConditions}
dataSource={runtimeDataSource}
/>
</motion.div>
) : null}
</AnimatePresence>
@@ -1222,6 +1278,8 @@ export function MapWorkbenchPage({
<ScheduledConditionFeed
presentation="mobile-sheet"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
@@ -1239,7 +1297,12 @@ export function MapWorkbenchPage({
</MobileWorkbenchSheet>
) : null}
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
<FeaturePopover
feature={detailFeature}
projectId={projectPresentation.projectId}
getAccessToken={getAccessToken}
onClose={() => setDetailFeature(null)}
/>
{!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
@@ -4,9 +4,15 @@ import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations"
annotations: "simulation-annotations",
backendAnalysis: "simulation-backend-analysis"
} as const;
const EMPTY_FEATURE_COLLECTION = {
type: "FeatureCollection" as const,
features: []
};
export const simulationSources = {
impactArea: {
type: "geojson",
@@ -15,6 +21,10 @@ export const simulationSources = {
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification,
backendAnalysis: {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION
} satisfies GeoJSONSourceSpecification
};
@@ -39,6 +49,39 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"line-opacity": 0.72
}
},
{
id: "simulation-backend-analysis-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": MAP_STYLE_TOKENS.state.agent,
"fill-opacity": 0.2
}
},
{
id: "simulation-backend-analysis-line",
type: "line",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": MAP_STYLE_TOKENS.state.agent,
"line-width": 3,
"line-opacity": 0.9
}
},
{
id: "simulation-backend-analysis-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.agent,
"circle-radius": 7,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 2
}
},
{
id: "simulation-burst-halo",
type: "circle",
@@ -116,13 +159,31 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-halo-width": 3
}
},
{
id: "simulation-backend-analysis-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
layout: {
"text-field": ["coalesce", ["get", "label"], ["get", "name"], "后端分析结果"],
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 1.2],
"text-anchor": "top",
"text-allow-overlap": false
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-field": "演示爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
@@ -0,0 +1,35 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { getWaterNetworkSourceId, toDetailFeature } from "./feature-adapter";
describe("feature adapter", () => {
it("classifies API-backed SCADA features by their canonical source id", () => {
const feature = {
id: "SCADA-1",
source: "scada",
properties: {
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1"
}
} as unknown as MapGeoJSONFeature;
expect(getWaterNetworkSourceId(feature)).toBe("scada");
expect(toDetailFeature(feature)).toMatchObject({
id: "SCADA-1",
layer: "scada",
title: "SCADA SCADA-1"
});
});
it("continues to classify vector features by source layer", () => {
const feature = {
id: "J-1",
source: "water-network",
sourceLayer: "junctions",
properties: { id: "J-1" }
} as unknown as MapGeoJSONFeature;
expect(getWaterNetworkSourceId(feature)).toBe("junctions");
});
});
+25 -1
View File
@@ -3,6 +3,8 @@ import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import {
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS,
isSourceGisSourceId,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
@@ -13,8 +15,12 @@ export function getFeatureId(feature: MapGeoJSONFeature) {
}
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
if ((WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(feature.source)) {
return feature.source as WaterNetworkSourceId;
}
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
if (feature.sourceLayer === sourceLayer) {
return sourceId;
}
}
@@ -37,6 +43,24 @@ export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
}
function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties: Record<string, unknown>) {
if (sourceId === "water_quality_monitoring_points") {
return {
title: `水质监测点 ${formatValue(properties.monitoring_point_no)}`,
subtitle: `节点 ${formatValue(properties.node_id ?? id)} · 灵敏度分析选点`
};
}
if (isSourceGisSourceId(sourceId)) {
const sourceName = formatValue(properties.source_layer_name);
const sourceIdLabel = formatValue(properties.source_object_id ?? properties.source_fid ?? id);
return {
title: `${sourceName} ${sourceIdLabel}`,
subtitle: sourceId === "fx_20200423_pipes" || sourceId === "fx_20200811_dn100_pipes"
? `管径 ${formatValue(properties.diameter)} · 长度 ${formatValue(properties.length)} m`
: `${formatValue(properties.dataset_title)} · 源要素 ${formatValue(properties.source_fid)}`
};
}
if (sourceId === "pipes") {
return {
title: `管线 ${id || "未命名"}`,
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
createGeoServerLayerHitsUrl,
fetchGeoServerLayerFeatureCount,
resolveGeoServerLayerAvailability
@@ -38,18 +39,22 @@ describe("GeoServer data-dependent layer availability", () => {
});
it("keeps only optional layers that contain features", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="3" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'));
const fetchMock = vi.fn((request: RequestInfo | URL) => {
const url = new URL(String(request));
const typeName = url.searchParams.get("typeNames");
return Promise.resolve(new Response(
`<wfs:FeatureCollection numberMatched="${typeName === "tjwater_next:pumps" ? 3 : 0}" />`
));
});
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions", "pumps"],
emptySourceIds: ["valves", "reservoirs", "tanks"],
failedSourceIds: []
emptySourceIds: DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) => sourceId !== "pumps"),
failedSourceIds: [],
featureCounts: Object.fromEntries(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map((sourceId) => [sourceId, sourceId === "pumps" ? 3 : 0])
)
});
});
@@ -63,18 +68,14 @@ describe("GeoServer data-dependent layer availability", () => {
});
it("omits invalid optional layers without blocking required layers", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"))
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"));
const fetchMock = vi.fn().mockResolvedValue(new Response("missing", { status: 503 }));
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions"],
emptySourceIds: [],
failedSourceIds: ["valves", "reservoirs", "pumps", "tanks"]
failedSourceIds: DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
featureCounts: {}
});
});
});
@@ -25,6 +25,7 @@ export type GeoServerLayerAvailability = {
availableSourceIds: GeoServerWaterNetworkSourceId[];
emptySourceIds: DataDependentGeoServerSourceId[];
failedSourceIds: DataDependentGeoServerSourceId[];
featureCounts: Partial<Record<DataDependentGeoServerSourceId, number>>;
};
export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SUPPLY_LAYER_CATALOG.filter(
@@ -84,15 +85,16 @@ export async function resolveGeoServerLayerAvailability(
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
const emptySourceIds: DataDependentGeoServerSourceId[] = [];
const failedSourceIds: DataDependentGeoServerSourceId[] = [];
const featureCounts: Partial<Record<DataDependentGeoServerSourceId, number>> = {};
results.forEach((result, index) => {
const sourceId = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS[index];
if (result.status === "rejected") {
failedSourceIds.push(sourceId);
} else if (result.value.count > 0) {
availableOptionalSourceIds.push(sourceId);
} else {
emptySourceIds.push(sourceId);
featureCounts[sourceId] = result.value.count;
if (result.value.count > 0) availableOptionalSourceIds.push(sourceId);
else emptySourceIds.push(sourceId);
}
});
@@ -106,6 +108,7 @@ export async function resolveGeoServerLayerAvailability(
availableSourceIdSet.has(sourceId)
),
emptySourceIds,
failedSourceIds
failedSourceIds,
featureCounts
};
}
+51 -1
View File
@@ -30,6 +30,49 @@ describe("supply network layer styling", () => {
});
});
it("styles source GIS pipes with the network diameter hierarchy and a distinct inventory trace", () => {
const sourcePipe = getLayer("fx_20200423_pipes-line");
const sourcePipePaint = sourcePipe?.paint as Record<string, unknown> | undefined;
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
const inventoryPaint = inventoryPipe?.paint as Record<string, unknown> | undefined;
expect(sourcePipePaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(JSON.stringify(sourcePipePaint?.["line-color"])).toContain("diameter");
expect((sourcePipePaint?.["line-color"] as unknown[])[1]).toEqual([
"<=",
["to-number", ["coalesce", ["get", "diameter"], 0]],
0
]);
expect(inventoryPaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(inventoryPaint?.["line-dasharray"]).toEqual([4, 2]);
});
it("uses distinct engineering symbols for every source GIS facility type", () => {
const sourceFacilitySymbols = [
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve, 14],
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve, 10],
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter, 10],
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant, 13],
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection, 13],
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter, 13],
[
"fx_20200423_non_residential_meters-symbol",
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter,
15
]
] as const;
sourceFacilitySymbols.forEach(([layerId, imageId, minZoom]) => {
expect(getLayer(layerId)).toMatchObject({
type: "symbol",
layout: {
"icon-image": imageId,
"icon-size": ["interpolate", ["linear"], ["zoom"], minZoom, 0.36, 18, 0.54]
}
});
});
});
it("keeps facilities visible from overview while deferring ordinary junctions", () => {
expect(getLayer("valves")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 8.5 });
@@ -46,6 +89,11 @@ describe("supply network layer styling", () => {
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
type: "symbol",
minzoom: 8,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.waterQuality }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -59,7 +107,9 @@ describe("supply network layer styling", () => {
const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("tanks-hit");
expect(ids.at(-1)).toBe("fx_20200423_non_residential_meters-hit");
expect(ids.indexOf("water_quality_monitoring_points-point"))
.toBeLessThan(ids.indexOf("water_quality_monitoring_points-hit"));
});
it("removes every layer backed by an unavailable optional source", () => {
+28 -9
View File
@@ -1,7 +1,13 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SCADA_HIT_LAYER_ID } from "./scada";
import { SCADA_HIT_LAYER_IDS } from "./scada";
import {
PUBLISHED_GIS_HIT_LAYER_IDS,
publishedGisBusinessLayers,
publishedGisHitLayers,
publishedGisInteractionLayers
} from "./published-gis-layers";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
@@ -27,8 +33,9 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID
] as const;
...SCADA_HIT_LAYER_IDS,
...PUBLISHED_GIS_HIT_LAYER_IDS
];
const hoveredOpacity: ExpressionSpecification = [
"case",
@@ -469,14 +476,26 @@ layers.push(
}
);
export const waterNetworkLayers: StyleSpecification["layers"] = layers as Layer[];
const interactionIndex = layers.findIndex((layer) => layer.id === "pipes-hover-outline");
const hitIndex = layers.findIndex((layer) => layer.id === INTERACTIVE_HIT_LAYER_IDS[0]);
const hitIndex = layers.findIndex((layer) => layer.id === "pipes-hit");
export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as Layer[];
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
export const waterNetworkBusinessLayers = [
...layers.slice(0, interactionIndex),
...publishedGisBusinessLayers
] as Layer[];
export const waterNetworkInteractionLayers = [
...layers.slice(interactionIndex, hitIndex),
...publishedGisInteractionLayers
] as Layer[];
export const waterNetworkHitLayers = [
...layers.slice(hitIndex),
...publishedGisHitLayers
] as Layer[];
export const waterNetworkLayers: StyleSpecification["layers"] = [
...waterNetworkBusinessLayers,
...waterNetworkInteractionLayers,
...waterNetworkHitLayers
];
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
export function filterWaterNetworkLayersBySourceIds(
+13 -1
View File
@@ -17,7 +17,19 @@ export const MAP_STYLE_TOKENS = {
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E",
scadaPressure: "#9A6700"
scadaPressure: "#9A6700",
waterQuality: "#0F766E",
sourcePipe: "#56808A",
sourcePipeMedium: "#397781",
sourcePipeMajor: "#315F73",
sourcePipeInventory: "#39738C",
sourceValve: "#737988",
sourceDrainValve: "#6D7F8C",
sourceFlowMeter: "#287A89",
sourceHydrant: "#A15F55",
sourceConnection: "#70818F",
sourceCustomerMeter: "#6F8292",
sourceNonResidentialMeter: "#526F83"
},
// Map overlays stay more chromatic than UI status surfaces so they remain legible over imagery.
state: {
@@ -6,32 +6,49 @@ import {
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map-control-config";
describe("workbench supply layer controls", () => {
it("creates controls only for available supply system sources", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
fx_20200423_pipes: 215_797,
fx_20200811_dn100_pipes: 122_686
});
expect(items.map((item) => item.id)).toEqual([
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([
"pipes",
"junctions",
"valves",
"reservoirs",
"scada",
"pumps",
"tanks",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
]);
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
"tjwater_next:pipes",
"tjwater_next:junctions",
"tjwater_next:valves",
"tjwater_next:reservoirs",
"后端业务 API",
"tjwater_next:pumps",
"tjwater_next:tanks"
]);
]));
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
group: "source-gis",
groupLabel: "GIS 原始数据",
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
featureCount: 215_797,
minZoom: 10,
visible: false
});
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
collection: "fx-pipe-inventory-20200811",
featureCount: 122_686,
visual: { kind: "line", dashed: true }
});
expect(items.find((item) => item.id === "water_quality_monitoring_points")).toMatchObject({
group: "monitoring",
groupLabel: "监测点",
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
visible: true
});
});
it("omits data-dependent controls when their GeoServer layers are empty", () => {
@@ -48,12 +65,13 @@ describe("workbench supply layer controls", () => {
"junctions",
"valves",
"reservoirs",
"scada",
"scada-pressure",
"scada-flow",
"simulation"
]);
});
it("shows only current visible business icons in the legend", () => {
it("defines map-matched symbols for model, source GIS, and analysis layers", () => {
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
"major-pipe",
"medium-pipe",
@@ -66,6 +84,19 @@ describe("workbench supply layer controls", () => {
"tank",
"scada-flow",
"scada-pressure",
"water-quality-monitoring",
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe",
"source-inventory-pipe",
"source-valve",
"source-drain-valve",
"source-flow-meter",
"source-hydrant",
"source-connection",
"source-customer-meter",
"source-non-residential-meter",
"impact-area"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" });
@@ -79,8 +110,80 @@ describe("workbench supply layer controls", () => {
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
shape: "dashed-line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-inventory-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
groupLabel: "原始 GIS 管线 · 2020-08-11",
layerControlId: "fx_20200811_dn100_pipes",
shape: "dashed-line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-unknown-pipe")).toMatchObject({
label: "未标注口径",
color: MAP_STYLE_TOKENS.state.inactive,
groupLabel: "原始 GIS 管线 · 2020-04-23",
layerControlId: "fx_20200423_pipes",
shape: "line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-valve")).toMatchObject({
imageSrc: "/map/gis-valve.svg",
groupLabel: "原始 GIS 设施"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
imageSrc: "/map/gis-hydrant.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
groupLabel: "分析覆盖",
layerControlId: "simulation",
shape: "area"
});
});
it("keeps the legend synchronized with visible layer controls", () => {
const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
expect(initialLegendIds).toEqual(expect.arrayContaining([
"major-pipe",
"valve",
"scada-flow",
"water-quality-monitoring"
]));
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
"source-major-pipe",
"source-inventory-pipe",
"impact-area"
]));
const sourcePipeItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
fx_20200423_pipes: true
});
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map(
(item) => item.id
);
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
]));
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
const reducedItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
valves: false,
simulation: false
});
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
expect(reducedLegendIds).not.toContain("valve");
expect(reducedLegendIds).not.toContain("impact-area");
});
it("finds every rendered layer that uses a source", () => {
+363 -32
View File
@@ -1,13 +1,21 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
import { GEOSERVER_WORKSPACE } from "./geoserver-config";
import type {
BaseLayerOption,
MapLayerControlItem,
MapLayerControlVisual,
MapLegendItem
} from "@/features/map/core";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type WaterNetworkSourceId
} from "./sources";
import { SCADA_ICON_PATHS } from "./scada";
import {
SCADA_ICON_PATHS,
SCADA_LAYER_CONTROL_ITEMS,
SCADA_LAYER_GROUPS
} from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
@@ -24,39 +32,306 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, true])),
...Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.id !== "scada")
.map((layer) => [layer.id, layer.initialVisible])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [
layer.id,
{
label: layer.label,
description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
.map((layer) => [layer.id, layer.label])
) as Record<WaterNetworkSourceId, string>;
type LayerControlPresentation = {
description: string;
minZoom?: number;
collection?: string;
collectionLabel?: string;
visual: MapLayerControlVisual;
};
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = {
pipes: {
description: "按管径分级",
minZoom: 8.5,
visual: {
kind: "line-stack",
colors: [
MAP_STYLE_TOKENS.supply.pipeMinor,
MAP_STYLE_TOKENS.supply.pipeMedium,
MAP_STYLE_TOKENS.supply.pipeMajor
]
}
},
junctions: {
description: "拓扑连接点",
minZoom: 15,
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
},
valves: {
description: "控制设施",
minZoom: 8.5,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
},
reservoirs: {
description: "供水设施",
minZoom: 8.5,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir }
},
pumps: {
description: "加压设施",
minZoom: 11,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
},
tanks: {
description: "调蓄设施",
minZoom: 11,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
},
water_quality_monitoring_points: {
description: "灵敏度分析选点",
minZoom: 8,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
},
fx_20200423_pipes: {
description: "全口径管线",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: {
kind: "line-stack",
colors: [
MAP_STYLE_TOKENS.supply.sourcePipe,
MAP_STYLE_TOKENS.supply.sourcePipeMedium,
MAP_STYLE_TOKENS.supply.sourcePipeMajor
]
}
},
fx_20200811_dn100_pipes: {
description: "DN100 及以上",
minZoom: 9,
collection: "fx-pipe-inventory-20200811",
collectionLabel: "2020-08-11 · 专项管线台账",
visual: {
kind: "line",
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
dashed: true
}
},
fx_20200423_valves: {
description: "源设施台账",
minZoom: 14,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
},
fx_20200423_drain_valves: {
description: "源设施台账",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
},
fx_20200423_flow_meters: {
description: "源仪表台账",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
},
fx_20200423_hydrants: {
description: "源设施台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
},
fx_20200423_service_connections: {
description: "源设施台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
},
fx_20200423_customer_meters: {
description: "源水表台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
},
fx_20200423_non_residential_meters: {
description: "源水表台账",
minZoom: 15,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
}
};
const MODEL_PIPE_LEGEND_GROUP = {
group: "model-pipe",
groupLabel: "模型管线",
groupDescription: "颜色与线宽共同表示口径",
groupLayout: "list"
} as const;
const MODEL_FACILITY_LEGEND_GROUP = {
group: "model-facility",
groupLabel: "供水设施",
groupDescription: "拓扑节点与关键设施",
groupLayout: "grid"
} as const;
const MONITORING_LEGEND_GROUP = {
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与水质选点",
groupLayout: "grid"
} as const;
const SOURCE_APRIL_PIPE_LEGEND_GROUP = {
group: "source-pipe-april",
groupLabel: "原始 GIS 管线 · 2020-04-23",
groupDescription: "全网快照,实线按已知口径分级",
groupLayout: "list"
} as const;
const SOURCE_AUGUST_PIPE_LEGEND_GROUP = {
group: "source-pipe-august",
groupLabel: "原始 GIS 管线 · 2020-08-11",
groupDescription: "DN100+ 专项台账,蓝色虚线",
groupLayout: "list"
} as const;
const SOURCE_FACILITY_LEGEND_GROUP = {
group: "source-facility",
groupLabel: "原始 GIS 设施",
groupDescription: "源设施与水表台账",
groupLayout: "grid"
} as const;
const ANALYSIS_LEGEND_GROUP = {
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "仅随空间分析成果显示",
groupLayout: "list"
} as const;
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "major-pipe", label: "DN600 及以上管线", color: MAP_STYLE_TOKENS.supply.pipeMajor, shape: "line" },
{ id: "medium-pipe", label: "DN300-DN599 管线", color: MAP_STYLE_TOKENS.supply.pipeMedium, shape: "line" },
{ id: "minor-pipe", label: "DN300 以下管线", color: MAP_STYLE_TOKENS.supply.pipeMinor, shape: "line" },
{ id: "inactive-pipe", label: "停运管线", color: MAP_STYLE_TOKENS.state.inactive, shape: "dashed-line" },
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank },
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
{
id: "major-pipe",
label: "DN600 及以上",
description: "主干管",
color: MAP_STYLE_TOKENS.supply.pipeMajor,
lineWidth: 5,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "medium-pipe",
label: "DN300-DN599",
description: "次干管",
color: MAP_STYLE_TOKENS.supply.pipeMedium,
lineWidth: 3.5,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "minor-pipe",
label: "DN300 以下",
description: "配水支管",
color: MAP_STYLE_TOKENS.supply.pipeMinor,
lineWidth: 2,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "inactive-pipe",
label: "停运管线",
description: "虚线",
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
shape: "dashed-line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot", layerControlId: "junctions", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve, layerControlId: "valves", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir, layerControlId: "reservoirs", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump, layerControlId: "pumps", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank, layerControlId: "tanks", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "scada-flow", label: "流量监测", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow, layerControlId: "scada-flow", ...MONITORING_LEGEND_GROUP },
{ id: "scada-pressure", label: "压力监测", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure, layerControlId: "scada-pressure", ...MONITORING_LEGEND_GROUP },
{ id: "water-quality-monitoring", label: "水质监测", color: MAP_STYLE_TOKENS.supply.waterQuality, imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality, layerControlId: "water_quality_monitoring_points", ...MONITORING_LEGEND_GROUP },
{
id: "source-major-pipe",
label: "DN600 及以上",
color: MAP_STYLE_TOKENS.supply.sourcePipeMajor,
lineWidth: 5,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-medium-pipe",
label: "DN300-DN599",
color: MAP_STYLE_TOKENS.supply.sourcePipeMedium,
lineWidth: 3.5,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-minor-pipe",
label: "DN300 以下",
color: MAP_STYLE_TOKENS.supply.sourcePipe,
lineWidth: 2,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-unknown-pipe",
label: "未标注口径",
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-inventory-pipe",
label: "DN100 及以上",
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
lineWidth: 3,
shape: "dashed-line",
layerControlId: "fx_20200811_dn100_pipes",
...SOURCE_AUGUST_PIPE_LEGEND_GROUP
},
{ id: "source-valve", label: "GIS 阀门", color: MAP_STYLE_TOKENS.supply.sourceValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve, layerControlId: "fx_20200423_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-drain-valve", label: "排水阀", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve, layerControlId: "fx_20200423_drain_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-flow-meter", label: "流量仪", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter, layerControlId: "fx_20200423_flow_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-hydrant", label: "消火栓", color: MAP_STYLE_TOKENS.supply.sourceHydrant, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant, layerControlId: "fx_20200423_hydrants", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-connection", label: "接水点", color: MAP_STYLE_TOKENS.supply.sourceConnection, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection, layerControlId: "fx_20200423_service_connections", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-customer-meter", label: "用户水表", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter, layerControlId: "fx_20200423_customer_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-non-residential-meter", label: "非居民水表", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter, layerControlId: "fx_20200423_non_residential_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "impact-area", label: "影响范围", description: "事故或分析结果", color: MAP_STYLE_TOKENS.state.incident, shape: "area", layerControlId: "simulation", ...ANALYSIS_LEGEND_GROUP }
];
export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
) {
const visibleLayerIds = new Set(
layerItems.filter((item) => item.visible).map((item) => item.id)
);
return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
);
}
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
@@ -94,19 +369,71 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
export function createLayerControlItems(
layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
): MapLayerControlItem[] {
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available && layer.id !== "scada" && available.has(layer.id)
);
const modelItems = catalogItems.filter((layer) => layer.group === "model");
const monitoringItems = catalogItems.filter((layer) => layer.group === "monitoring");
const sourceGisItems = catalogItems.filter((layer) => layer.group === "source-gis");
return [
...availableSourceIds.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
...modelItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "model",
groupLabel: "管网模型",
groupDescription: "计算模型与基础设施"
})),
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})) : []),
...monitoringItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})),
...sourceGisItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "source-gis",
groupLabel: "GIS 原始数据",
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false
})),
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
visible: layerVisibility.simulation,
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "随已打开的空间成果显示"
}
];
}
@@ -115,6 +442,10 @@ export function getWorkbenchLayerIds(
map: Pick<MapLibreMap, "getLayer" | "getStyle">,
groupId: string
) {
if (groupId in SCADA_LAYER_GROUPS) {
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS]
.filter((layerId) => map.getLayer(layerId));
}
if (isWaterNetworkSourceId(groupId)) {
return map
.getStyle()
@@ -6,14 +6,9 @@ import {
type GeoServerWaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
pumps: "id",
tanks: "id"
};
export const MAP_FEATURE_ID_FIELDS = Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "id"])
) as Record<GeoServerWaterNetworkSourceId, "id">;
export const MAX_MAP_FEATURE_IDS = 100;
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
@@ -0,0 +1,268 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
} from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
type Layer = NonNullable<StyleSpecification["layers"]>[number];
type PublishedLayerDefinition = {
sourceId: GeoServerWaterNetworkSourceId;
geometry: "line" | "point";
color: string;
minzoom: number;
radius?: number;
imageId?: string;
lineVariant?: "network" | "inventory";
};
const definitions: PublishedLayerDefinition[] = [
{
sourceId: "water_quality_monitoring_points",
geometry: "point",
color: MAP_STYLE_TOKENS.supply.waterQuality,
minzoom: 8,
radius: 5.5,
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
},
{ sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: 10, lineVariant: "network" },
{ sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: 9, lineVariant: "inventory" },
{ sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: 14, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
{ sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: 10, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
{ sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: 10, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
{ sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: 13, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
{ sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
{ sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
{ sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: 15, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
];
const sourcePipeColorExpression: ExpressionSpecification = [
"case",
["<=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 0],
MAP_STYLE_TOKENS.state.inactive,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 600],
MAP_STYLE_TOKENS.supply.sourcePipeMajor,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 300],
MAP_STYLE_TOKENS.supply.sourcePipeMedium,
MAP_STYLE_TOKENS.supply.sourcePipe
];
const hoveredOpacity: ExpressionSpecification = [
"case",
["boolean", ["feature-state", "hovered"], false],
1,
0
];
const selectedOpacity: ExpressionSpecification = [
"case",
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
1,
0
];
function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
if (definition.geometry === "line") {
const lineColor = definition.lineVariant === "network"
? sourcePipeColorExpression
: definition.color;
const lineDash = definition.lineVariant === "inventory" ? [4, 2] as [number, number] : [1, 0] as [number, number];
return [
{
id: `${definition.sourceId}-casing`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"line-opacity": 0.82,
"line-dasharray": lineDash
}
},
{
id: `${definition.sourceId}-line`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": lineColor,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
"line-opacity": definition.lineVariant === "inventory" ? 0.92 : 0.86,
"line-dasharray": lineDash
}
}
];
}
const radius = definition.radius ?? 3.2;
const iconSize = definition.sourceId === "water_quality_monitoring_points"
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.25, 18, 0.42] as ExpressionSpecification
: ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.36, 18, 0.54] as ExpressionSpecification;
const layers: Layer[] = [
{
id: `${definition.sourceId}-halo`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": radius + 1.8,
"circle-opacity": 0.9
}
},
{
id: `${definition.sourceId}-point`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": definition.color,
"circle-radius": ["interpolate", ["linear"], ["zoom"], definition.minzoom, radius, 18, radius * 1.35],
"circle-opacity": 0.9,
"circle-stroke-color": definition.sourceId === "water_quality_monitoring_points"
? MAP_STYLE_TOKENS.canvas.casing
: definition.color,
"circle-stroke-width": definition.sourceId === "water_quality_monitoring_points" ? 1.5 : 0
}
}
];
if (definition.imageId) {
layers.push({
id: `${definition.sourceId}-symbol`,
type: "symbol",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
layout: {
"icon-image": definition.imageId,
"icon-size": iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": 0.96
}
});
}
return layers;
}
function createInteractionLayers(definition: PublishedLayerDefinition): Layer[] {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
if (definition.geometry === "line") {
const lineColor = definition.lineVariant === "network"
? sourcePipeColorExpression
: definition.color;
return [
{
id: `${definition.sourceId}-hover`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": lineColor,
"line-width": SUPPLY_LAYER_VISUALS.pipes.hoverWidth,
"line-opacity": hoveredOpacity
}
},
{
id: `${definition.sourceId}-selected`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": SUPPLY_LAYER_VISUALS.pipes.selectedWidth,
"line-opacity": selectedOpacity
}
}
];
}
const radius = definition.radius ?? 3.2;
const hoverRadius = definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 10, 18, 15] as ExpressionSpecification
: radius + 4;
const selectedRadius = definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 11, 18, 16] as ExpressionSpecification
: radius + 4.5;
return [
{
id: `${definition.sourceId}-hover`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": hoverRadius,
"circle-stroke-color": definition.color,
"circle-stroke-width": 2,
"circle-opacity": hoveredOpacity,
"circle-stroke-opacity": hoveredOpacity
}
},
{
id: `${definition.sourceId}-selected`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": selectedRadius,
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
"circle-stroke-width": 2.5,
"circle-opacity": selectedOpacity,
"circle-stroke-opacity": selectedOpacity
}
}
];
}
function createHitLayer(definition: PublishedLayerDefinition): Layer {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
return definition.geometry === "line"
? {
id: `${definition.sourceId}-hit`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.transparent,
"line-width": SUPPLY_LAYER_VISUALS.pipes.hitWidth,
"line-opacity": 0
}
}
: {
id: `${definition.sourceId}-hit`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 12, 18, 18]
: Math.max((definition.radius ?? 3.2) + 5, 8),
"circle-opacity": 0
}
};
}
export const publishedGisBusinessLayers = definitions.flatMap(createBusinessLayers);
export const publishedGisInteractionLayers = definitions.flatMap(createInteractionLayers);
export const publishedGisHitLayers = definitions.map(createHitLayer);
export const PUBLISHED_GIS_HIT_LAYER_IDS = publishedGisHitLayers.map((layer) => layer.id);
+16 -15
View File
@@ -1,7 +1,7 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
SCADA_HIT_LAYER_ID,
SCADA_HIT_LAYER_IDS,
SCADA_ICON_EXPRESSION,
SCADA_IMAGE_IDS,
SCADA_MAP_ICON_PATHS,
@@ -24,8 +24,8 @@ describe("SCADA map presentation", () => {
});
it("keeps the hit target last in normal and fallback presentations", () => {
expect(scadaLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
expect(scadaLayers.slice(-2).map((layer) => layer.id)).toEqual(SCADA_HIT_LAYER_IDS);
expect(scadaFallbackLayers.slice(-2).map((layer) => layer.id)).toEqual(SCADA_HIT_LAYER_IDS);
});
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
@@ -36,9 +36,9 @@ describe("SCADA map presentation", () => {
});
it("keeps the icon and selected ring consistent with junction selection", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-selected-outer");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-selected");
const junctionOuterLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected-outer");
const junctionSelectedLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected");
const junctionOuterPaint = junctionOuterLayer?.paint as Record<string, unknown> | undefined;
@@ -47,7 +47,7 @@ describe("SCADA map presentation", () => {
expect(symbolLayer).toMatchObject({
type: "symbol",
layout: {
"icon-image": SCADA_ICON_EXPRESSION,
"icon-image": SCADA_IMAGE_IDS.pressure,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize
}
});
@@ -68,12 +68,13 @@ describe("SCADA map presentation", () => {
});
it("uses distinct colors for authoritative pressure and pipe flow types", () => {
const fillLayer = scadaLayers.find((layer) => layer.id === "scada-fill");
const colorExpression = JSON.stringify((fillLayer as { paint?: unknown })?.paint);
const pressureFill = scadaLayers.find((layer) => layer.id === "scada-pressure-fill");
const flowFill = scadaLayers.find((layer) => layer.id === "scada-flow-fill");
expect(colorExpression).toContain("pipe_flow");
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaFlow);
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaPressure);
expect(JSON.stringify((pressureFill as { paint?: unknown })?.paint)).toContain(MAP_STYLE_TOKENS.supply.scadaPressure);
expect(JSON.stringify((flowFill as { paint?: unknown })?.paint)).toContain(MAP_STYLE_TOKENS.supply.scadaFlow);
expect(JSON.stringify(pressureFill && "filter" in pressureFill ? pressureFill.filter : undefined)).toContain("pressure");
expect(JSON.stringify(flowFill && "filter" in flowFill ? flowFill.filter : undefined)).toContain("pipe_flow");
});
it("uses MapLibre-compatible PNG assets", () => {
@@ -112,9 +113,9 @@ describe("SCADA map presentation", () => {
failedImageIds: Object.values(SCADA_IMAGE_IDS)
});
expect(scadaFallbackLayers.map((layer) => layer.id).slice(0, 3)).toEqual([
"scada-casing",
"scada-fill",
"scada-fallback"
"scada-pressure-casing",
"scada-pressure-fill",
"scada-pressure-fallback"
]);
});
});
+167 -63
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
flow: "/map/scada-flow.png",
@@ -29,6 +28,27 @@ export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
SCADA_IMAGE_IDS.pressure
];
export const SCADA_LAYER_CONTROL_ITEMS = [
{
id: "scada-pressure",
label: "压力监测点",
description: "83 个压力监测点",
deviceType: "pressure",
imageId: SCADA_IMAGE_IDS.pressure,
color: MAP_STYLE_TOKENS.supply.scadaPressure
},
{
id: "scada-flow",
label: "流量监测点",
description: "35 个流量监测点",
deviceType: "pipe_flow",
imageId: SCADA_IMAGE_IDS.flow,
color: MAP_STYLE_TOKENS.supply.scadaFlow
}
] as const;
export type ScadaLayerControlId = (typeof SCADA_LAYER_CONTROL_ITEMS)[number]["id"];
const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [
"case",
state === "selected"
@@ -38,12 +58,6 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
0
];
const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure
];
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
@@ -51,74 +65,164 @@ export type ScadaImageRegistrationResult = {
failedImageIds: string[];
};
const scadaCasingLayer: Layer = {
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": 0.94
}
};
const scadaFillLayer: Layer = {
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": createSupplyStateColor(scadaColor),
"circle-opacity": supplyFacilityOpacityExpression
}
};
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
paint: { "icon-opacity": supplyIconOpacityExpression }
};
const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": supplyIconOpacityExpression
}
};
export const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
const typeFilter = (deviceType: string): ExpressionSpecification => [
"==",
["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
deviceType
];
const scadaHitLayer: Layer = {
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
};
function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number]) {
const filter = typeFilter(definition.deviceType);
const color = createSupplyStateColor(definition.color);
const business: Layer[] = [
{
id: `${definition.id}-casing`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": 0.94
}
},
{
id: `${definition.id}-fill`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": color,
"circle-opacity": supplyFacilityOpacityExpression
}
},
{
id: `${definition.id}-symbol`,
type: "symbol",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: {
"icon-image": definition.imageId,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: { "icon-opacity": supplyIconOpacityExpression }
}
];
const fallback: Layer[] = [
...business.slice(0, 2),
{
id: `${definition.id}-fallback`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": supplyIconOpacityExpression
}
}
];
const interaction: Layer[] = [
{
id: `${definition.id}-hover`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-stroke-color": definition.color,
"circle-stroke-width": 2,
"circle-opacity": stateOpacity("hovered"),
"circle-stroke-opacity": stateOpacity("hovered")
}
},
{
id: `${definition.id}-selected-outer`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 5,
"circle-opacity": stateOpacity("selected"),
"circle-stroke-opacity": stateOpacity("selected")
}
},
{
id: `${definition.id}-selected`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
"circle-stroke-width": 2.5,
"circle-opacity": stateOpacity("selected"),
"circle-stroke-opacity": stateOpacity("selected")
}
}
];
const hit: Layer = {
id: `${definition.id}-hit`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-opacity": 0
}
};
return { business, fallback, interaction, hit };
}
export const scadaBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaSymbolLayer
];
const groups = SCADA_LAYER_CONTROL_ITEMS.map((definition) => ({
definition,
layers: createScadaLayers(definition)
}));
export const scadaFallbackBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaFallbackLayer
];
export const scadaBusinessLayers = groups.flatMap((group) => group.layers.business);
export const scadaFallbackBusinessLayers = groups.flatMap((group) => group.layers.fallback);
export const scadaInteractionLayers = groups.flatMap((group) => group.layers.interaction);
export const scadaHitLayers = groups.map((group) => group.layers.hit);
export const SCADA_HIT_LAYER_IDS = scadaHitLayers.map((layer) => layer.id);
export const SCADA_HIT_LAYER_ID = SCADA_HIT_LAYER_IDS[0];
export const scadaHitLayers: Layer[] = [scadaHitLayer];
export const SCADA_LAYER_GROUPS = Object.fromEntries(groups.map(({ definition, layers }) => [
definition.id,
[
...layers.business.map((layer) => layer.id),
...layers.fallback.map((layer) => layer.id),
...layers.interaction.map((layer) => layer.id),
layers.hit.id
]
])) as Record<ScadaLayerControlId, string[]>;
export const scadaLayers: Layer[] = [
export const scadaLayers = [
...scadaBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
...scadaHitLayers
];
export const scadaFallbackLayers: Layer[] = [
export const scadaFallbackLayers = [
...scadaFallbackBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
...scadaHitLayers
];
export async function registerScadaImages(
+14 -19
View File
@@ -11,24 +11,22 @@ import {
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
expect(SOURCE_LAYERS).toMatchObject({
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
tanks: "tanks",
water_quality_monitoring_points: "water_quality_monitoring_points",
fx_20200423_pipes: "fx_20200423_pipes",
fx_20200811_dn100_pipes: "fx_20200811_dn100_pipes"
});
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
["pipes", true, "required"],
["junctions", true, "required"],
["valves", true, "probe"],
["reservoirs", true, "probe"],
["scada", true, "required"],
["pumps", true, "probe"],
["tanks", true, "probe"]
]);
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points"))
.toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "valves"
@@ -36,7 +34,7 @@ describe("createWaterNetworkSources", () => {
});
it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(new Set(AVAILABLE_WATER_NETWORK_SOURCE_IDS)).toEqual(new Set(WATER_NETWORK_SOURCE_IDS));
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库",
icon: "reservoir"
@@ -49,16 +47,13 @@ describe("createWaterNetworkSources", () => {
expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
]);
expect(sources[sourceId].promoteId).toBe("id");
expect(sources[sourceId].bounds).toEqual([
121.35162408429075,
30.81049932304578,
121.77248479490075,
31.007207809222017
]);
expect(sources[sourceId].bounds).toBeUndefined();
});
expect("pumps" in sources).toBe(true);
expect("tanks" in sources).toBe(true);
expect("scada" in sources).toBe(false);
expect("water_quality_monitoring_points" in sources).toBe(true);
expect("fx_20200423_pipes" in sources).toBe(true);
});
it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => {
+165 -13
View File
@@ -9,8 +9,7 @@ import {
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742],
bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017]
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
} as const;
export const SOURCE_LAYERS = {
@@ -19,7 +18,17 @@ export const SOURCE_LAYERS = {
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
tanks: "tanks",
water_quality_monitoring_points: "water_quality_monitoring_points",
fx_20200423_drain_valves: "fx_20200423_drain_valves",
fx_20200423_service_connections: "fx_20200423_service_connections",
fx_20200423_flow_meters: "fx_20200423_flow_meters",
fx_20200423_hydrants: "fx_20200423_hydrants",
fx_20200423_customer_meters: "fx_20200423_customer_meters",
fx_20200423_pipes: "fx_20200423_pipes",
fx_20200423_valves: "fx_20200423_valves",
fx_20200423_non_residential_meters: "fx_20200423_non_residential_meters",
fx_20200811_dn100_pipes: "fx_20200811_dn100_pipes"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -29,7 +38,17 @@ export const WATER_NETWORK_SOURCE_IDS = [
"reservoirs",
"scada",
"pumps",
"tanks"
"tanks",
"water_quality_monitoring_points",
"fx_20200423_drain_valves",
"fx_20200423_service_connections",
"fx_20200423_flow_meters",
"fx_20200423_hydrants",
"fx_20200423_customer_meters",
"fx_20200423_pipes",
"fx_20200423_valves",
"fx_20200423_non_residential_meters",
"fx_20200811_dn100_pipes"
] as const;
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
@@ -42,6 +61,8 @@ export type SupplyLayerCatalogItem = {
availability: "required" | "probe";
label: string;
icon: string;
group: "model" | "monitoring" | "source-gis";
initialVisible: boolean;
};
export const SUPPLY_LAYER_CATALOG = [
@@ -52,7 +73,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "管线",
icon: "pipe"
icon: "pipe",
group: "model",
initialVisible: true
},
{
id: "junctions",
@@ -61,7 +84,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "节点",
icon: "junction"
icon: "junction",
group: "model",
initialVisible: true
},
{
id: "valves",
@@ -70,7 +95,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "阀门",
icon: "valve"
icon: "valve",
group: "model",
initialVisible: true
},
{
id: "reservoirs",
@@ -79,7 +106,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水库",
icon: "reservoir"
icon: "reservoir",
group: "model",
initialVisible: true
},
{
id: "scada",
@@ -88,7 +117,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "SCADA",
icon: "scada-pressure"
icon: "scada-pressure",
group: "monitoring",
initialVisible: true
},
{
id: "pumps",
@@ -97,7 +128,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水泵",
icon: "pump"
icon: "pump",
group: "model",
initialVisible: true
},
{
id: "tanks",
@@ -106,10 +139,130 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水箱",
icon: "tank"
icon: "tank",
group: "model",
initialVisible: true
},
{
id: "water_quality_monitoring_points",
sourceLayer: SOURCE_LAYERS.water_quality_monitoring_points,
geometry: "point",
available: true,
availability: "probe",
label: "水质监测点",
icon: "water-quality",
group: "monitoring",
initialVisible: true
},
{
id: "fx_20200423_pipes",
sourceLayer: SOURCE_LAYERS.fx_20200423_pipes,
geometry: "line",
available: true,
availability: "probe",
label: "GIS 管线",
icon: "source-pipe",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200811_dn100_pipes",
sourceLayer: SOURCE_LAYERS.fx_20200811_dn100_pipes,
geometry: "line",
available: true,
availability: "probe",
label: "DN100+ 台账",
icon: "source-pipe",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_valves",
sourceLayer: SOURCE_LAYERS.fx_20200423_valves,
geometry: "point",
available: true,
availability: "probe",
label: "GIS 阀门",
icon: "source-valve",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_drain_valves",
sourceLayer: SOURCE_LAYERS.fx_20200423_drain_valves,
geometry: "point",
available: true,
availability: "probe",
label: "排水阀",
icon: "source-valve",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_flow_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_flow_meters,
geometry: "point",
available: true,
availability: "probe",
label: "流量仪",
icon: "source-meter",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_hydrants",
sourceLayer: SOURCE_LAYERS.fx_20200423_hydrants,
geometry: "point",
available: true,
availability: "probe",
label: "消火栓",
icon: "source-hydrant",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_service_connections",
sourceLayer: SOURCE_LAYERS.fx_20200423_service_connections,
geometry: "point",
available: true,
availability: "probe",
label: "接水点",
icon: "source-connection",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_customer_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_customer_meters,
geometry: "point",
available: true,
availability: "probe",
label: "用户水表",
icon: "source-meter",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_non_residential_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_non_residential_meters,
geometry: "point",
available: true,
availability: "probe",
label: "非居民水表",
icon: "source-meter",
group: "source-gis",
initialVisible: false
}
] as const satisfies readonly SupplyLayerCatalogItem[];
export const SOURCE_GIS_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.group === "source-gis"
).map((layer) => layer.id) as WaterNetworkSourceId[];
export function isSourceGisSourceId(value: string): value is WaterNetworkSourceId {
return (SOURCE_GIS_SOURCE_IDS as readonly string[]).includes(value);
}
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available
).map((layer) => layer.id) as WaterNetworkSourceId[];
@@ -155,8 +308,7 @@ function createGeoServerVectorSource(
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
minzoom: 0,
maxzoom: 24,
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
maxzoom: 24
};
}
@@ -4,13 +4,28 @@ import { describe, expect, it, vi } from "vitest";
import {
SUPPLY_ASSET_ICON_PATHS,
SUPPLY_ASSET_IMAGE_IDS,
SUPPLY_ASSET_MAP_ICON_PATHS,
registerSupplyAssetImages
} from "./supply-icons";
describe("supply facility map icons", () => {
it("uses MapLibre-compatible PNG assets from the public map directory", () => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual(["valve", "reservoir", "pump", "tank"]);
Object.values(SUPPLY_ASSET_ICON_PATHS).forEach((path) => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
"valve",
"reservoir",
"pump",
"tank",
"waterQuality",
"sourceValve",
"sourceDrainValve",
"sourceFlowMeter",
"sourceHydrant",
"sourceConnection",
"sourceCustomerMeter",
"sourceNonResidentialMeter"
]);
expect(SUPPLY_ASSET_ICON_PATHS.waterQuality).toBe("/map/water-quality-monitoring.svg");
Object.values(SUPPLY_ASSET_MAP_ICON_PATHS).forEach((path) => {
expect(path).toMatch(/^\/map\/.+\.png$/);
});
});
+43 -4
View File
@@ -4,24 +4,63 @@ export const SUPPLY_ASSET_IMAGE_IDS = {
valve: "supply-valve",
reservoir: "supply-reservoir",
pump: "supply-pump",
tank: "supply-tank"
tank: "supply-tank",
waterQuality: "water-quality-monitoring",
sourceValve: "source-gis-valve",
sourceDrainValve: "source-gis-drain-valve",
sourceFlowMeter: "source-gis-flow-meter",
sourceHydrant: "source-gis-hydrant",
sourceConnection: "source-gis-service-connection",
sourceCustomerMeter: "source-gis-customer-meter",
sourceNonResidentialMeter: "source-gis-non-residential-meter"
} as const;
export const SUPPLY_ASSET_ICON_PATHS = {
valve: "/map/valve.png",
reservoir: "/map/reservoir.png",
pump: "/map/pump.png",
tank: "/map/tank.png"
tank: "/map/tank.png",
waterQuality: "/map/water-quality-monitoring.svg",
sourceValve: "/map/gis-valve.svg",
sourceDrainValve: "/map/gis-drain-valve.svg",
sourceFlowMeter: "/map/gis-flow-meter.svg",
sourceHydrant: "/map/gis-hydrant.svg",
sourceConnection: "/map/gis-service-connection.svg",
sourceCustomerMeter: "/map/gis-customer-meter.svg",
sourceNonResidentialMeter: "/map/gis-non-residential-meter.svg"
} as const;
export const SUPPLY_ASSET_MAP_ICON_PATHS = SUPPLY_ASSET_ICON_PATHS;
export const SUPPLY_ASSET_MAP_ICON_PATHS = {
...SUPPLY_ASSET_ICON_PATHS,
waterQuality: "/map/water-quality-monitoring.png",
sourceValve: "/map/gis-valve.png",
sourceDrainValve: "/map/gis-drain-valve.png",
sourceFlowMeter: "/map/gis-flow-meter.png",
sourceHydrant: "/map/gis-hydrant.png",
sourceConnection: "/map/gis-service-connection.png",
sourceCustomerMeter: "/map/gis-customer-meter.png",
sourceNonResidentialMeter: "/map/gis-non-residential-meter.png"
} as const;
export type SupplyAssetImageRegistrationResult = {
status: "ready" | "degraded";
failedImageIds: string[];
};
const ACTIVE_SUPPLY_ASSET_ICON_KEYS = ["valve", "reservoir", "pump", "tank"] as const;
const ACTIVE_SUPPLY_ASSET_ICON_KEYS = [
"valve",
"reservoir",
"pump",
"tank",
"waterQuality",
"sourceValve",
"sourceDrainValve",
"sourceFlowMeter",
"sourceHydrant",
"sourceConnection",
"sourceCustomerMeter",
"sourceNonResidentialMeter"
] as const;
export async function registerSupplyAssetImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
+4 -4
View File
@@ -1,4 +1,4 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import { Layers3, ListTree, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
@@ -16,9 +16,9 @@ export const waterNetworkToolbarItems: MapToolbarItem[] = [
},
{
id: "analysis",
label: "分析",
description: "查看地图分析图例",
icon: BarChart3
label: "图例",
description: "查看当前地图符号",
icon: ListTree
},
{
id: "annotation",
@@ -24,13 +24,29 @@ const workOrder: ScheduledConditionItem = {
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
subtasks: [{
id: "work-1-subtask-1",
title: "调整阀门开度",
summary: "现场调整并记录开度。",
status: "running",
scheduledAt: "2026-08-19T10:00:00.000Z",
location: "边界阀门 BV-07",
assignee: "班组",
durationMinutes: 40,
replyWindowMinutes: 20,
stages: [{
id: "execution",
sequence: 1,
title: "执行",
status: "current",
owner: "班组",
timeLabel: "10:00 - 10:40",
description: "现场调整阀门。"
}],
replyRequirements: ["回传阀门开度"]
}]
};
describe("operational timeline model", () => {
@@ -176,10 +176,13 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const subtask = getRepresentativeWorkOrderSubtask(item);
const planEvent: OperationalEvent = {
id: `plan-${item.id}`,
title: item.title,
description: `${item.location} · ${item.assignee}`,
description: subtask
? `${subtask.title} · ${subtask.location} · ${subtask.assignee}`
: item.summary,
lane: "plan",
status: "planned",
importance: item.priority === "urgent" ? "important" : "normal",
@@ -210,8 +213,8 @@ function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[]
cursorTime: actualTime,
description:
item.status === "running"
? `${item.assignee}正在执行,位置:${item.location}`
: `${item.assignee}已完成现场执行,等待调度复令`
? `${subtask?.assignee ?? "执行班组"}正在执行,位置:${subtask?.location ?? "待确认"}`
: `${subtask?.assignee ?? "执行班组"}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
@@ -228,13 +231,32 @@ function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[]
}
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
if (item.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
}
if (item.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
}
return [];
const targets = item.subtasks.flatMap((subtask): FeatureTarget[] => {
if (subtask.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(subtask.location) }];
}
if (subtask.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(subtask.location) }];
}
return [];
});
return targets.filter((target, index) =>
targets.findIndex((candidate) =>
candidate.sourceId === target.sourceId && candidate.featureId === target.featureId
) === index
);
}
function getRepresentativeWorkOrderSubtask(item: ScheduledWorkOrderItem) {
return item.subtasks.find((subtask) => subtask.status === "running")
?? item.subtasks.find((subtask) => subtask.status === "blocked")
?? item.subtasks.find((subtask) =>
subtask.stages.some((stage) => stage.status === "current")
)
?? item.subtasks.find((subtask) => subtask.status === "pending")
?? item.subtasks.at(-1)
?? null;
}
function extractAssetId(location: string) {
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/shared/config/env", () => ({
env: { TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test" }
}));
vi.mock("../api/project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project-1" } })),
createWorkbenchHeaders: (token: string | undefined, projectId: string, json: boolean) => {
const headers = new Headers({ Accept: "application/json", "X-Project-Id": projectId });
if (token) headers.set("Authorization", `Bearer ${token}`);
if (json) headers.set("Content-Type", "application/json");
return headers;
}
}));
import { PeripheralSampleClient, type PeripheralTask } from "./client";
const task = (revision: number, feedback: PeripheralTask["feedback"] = []): PeripheralTask => ({
task_id: "10000000-0000-4000-8000-000000000001", project_id: "project-1", revision,
title: "核查", summary: "摘要", source: { system: "next", task_id: "20000000-0000-4000-8000-000000000001", object_type: "validation", object_id: "30000000-0000-4000-8000-000000000001", revision: 1 },
asset_ids: ["DEMO-P001"], analysis_window: { start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z" },
evidence_refs: [], requested_check: "核查", status: "processing", origin: "synthetic",
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", created_by: "user", history: [], feedback
});
describe("PeripheralSampleClient", () => {
beforeEach(() => vi.restoreAllMocks());
it("reads the persisted synthetic demo dataset without issuing a write", async () => {
const dataset = { generator_version: "water-demo@1", seed: "fixed", origin: "synthetic", window: { start: "2026-09-17T00:00:00Z", end: "2026-09-17T12:00:00Z" }, points: [], observations: [], device_states: [], alerts: [], operations: [], project_id: "project-1", source_id: "peripheral-demo", source_revision: "demo-r1" };
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(dataset), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
expect((await new PeripheralSampleClient().getDemoData()).seed).toBe("fixed");
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/api/sample/v1/demo-data");
expect(fetchMock.mock.calls[0]?.[1]?.method).toBeUndefined();
});
it("rejects malformed and cross-project successful read responses", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ project_id: "project-1" }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...task(1), project_id: "project-2" }), { status: 200 })));
const client = new PeripheralSampleClient();
await expect(client.getDemoData()).rejects.toMatchObject({ code: "INVALID_RESPONSE" });
await expect(client.getTask(task(1).task_id)).rejects.toMatchObject({
code: "INVALID_RESPONSE",
effect: "none"
});
});
it("keeps the newer task projection when a late state arrives", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(task(4)), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(task(3)), { status: 200 })));
const client = new PeripheralSampleClient(async () => "token");
expect((await client.getTask(task(4).task_id)).revision).toBe(4);
expect((await client.getTask(task(4).task_id)).revision).toBe(4);
});
it("deduplicates feedback by feedback_id", async () => {
const feedback = { feedback_id: "40000000-0000-4000-8000-000000000001", task_id: task(1).task_id, recorded_by: "user", recorded_at: "2026-01-01T00:00:00Z", occurred_at: "2026-01-01T00:00:00Z", applicable_time: { start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z" }, asset_ids: ["DEMO-P001"], finding: "uncertain" as const, notes: "待复核", attachment_refs: [], origin: "synthetic" as const };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(task(2, [feedback, feedback])), { status: 200 })));
expect((await new PeripheralSampleClient().getTask(task(1).task_id)).feedback).toHaveLength(1);
});
it("queries the receipt after an unknown-effect response without repeating POST", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "RESPONSE_LOST", message: "lost", effect: "unknown", request_id: "request-1" }), { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request-1", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const result = await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source });
expect(result.revision).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
});
it("retains an unresolved network submission and later queries the same receipt", async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new TypeError("socket closed"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "ignored", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
const input = { ...task(1), source: task(1).source };
await expect(client.createTask(input)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
expect((await client.createTask(input)).revision).toBe(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
expect(fetchMock.mock.calls[1]?.[0]).toEqual(fetchMock.mock.calls[2]?.[0]);
});
it("treats an unparsable 503 POST response as unknown and recovers by receipt", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response("gateway noise", { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
});
it("does not claim an unknown write when token acquisition fails before fetch", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient(async () => { throw new Error("token unavailable"); });
await expect(client.createTask({ ...task(1), source: task(1).source })).rejects.toThrow("token unavailable");
expect(fetchMock).not.toHaveBeenCalled();
});
it("routes an invalid successful POST body through receipt recovery", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("blocks changed command content while an earlier request remains unresolved", async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new TypeError("disconnected"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
await expect(client.createTask({ ...task(1), source: task(1).source })).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
await expect(client.createTask({ ...task(1), title: "另一项核查", source: task(1).source })).rejects.toMatchObject({ code: "PENDING_COMMAND_MISMATCH" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("manual receipt success clears only the matching pending command", async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new TypeError("disconnected"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "saved-request", task_id: task(2).task_id, result: task(2) }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(task(3)), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
const input = { ...task(1), source: task(1).source };
await expect(client.createTask(input)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
const receiptUrl = String(fetchMock.mock.calls[1]?.[0]);
const requestId = receiptUrl.split("/").at(-1)!;
expect("task_id" in await client.getReceipt(requestId)).toBe(true);
expect((await client.createTask({ ...input, title: "另一项核查" })).revision).toBe(3);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(2);
});
it("uses versioned PATCH and DELETE for managed test work orders", async () => {
const order = {
work_order_id: "50000000-0000-4000-8000-000000000001", project_id: "project-1", revision: 2,
title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const,
priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const,
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
created_by: "user", updated_by: "user"
};
const deleted = { resource_type: "work_order" as const, resource_id: order.work_order_id,
revision: 3, deleted: true as const };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(order), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(deleted), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
await expect(client.updateWorkOrder(order.work_order_id, {
expected_revision: 1, status: "in_progress", clear_assignee: false, clear_scheduled_at: false
})).resolves.toEqual(order);
await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted);
expect(fetchMock.mock.calls.map(([, init]) => init?.method)).toEqual(["PATCH", "DELETE"]);
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ expected_revision: 2 });
});
});
@@ -0,0 +1,415 @@
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
import type { components } from "./generated/schema";
type Schemas = components["schemas"];
export type PeripheralContext = Schemas["Context"];
export type PeripheralTask = Schemas["Task"];
export type PeripheralTaskPage = Schemas["TaskPage"];
export type PeripheralCreateTask = Schemas["CreateTask"];
export type PeripheralTransition = Schemas["Transition"];
export type PeripheralAddFeedback = Schemas["AddFeedback"];
export type PeripheralScenarioState = Schemas["ScenarioState"];
export type PeripheralScenario = PeripheralScenarioState["scenario"];
export type PeripheralPointPage = Schemas["PointPage"];
export type PeripheralAlertPage = Schemas["AlertPage"];
export type PeripheralObservations = Schemas["Observations"];
export type PeripheralDemoDataset = Schemas["DemoDataset"];
export type PeripheralWorkOrder = Schemas["WorkOrder"];
export type PeripheralWorkOrderPage = Schemas["WorkOrderPage"];
export type PeripheralCreateWorkOrder = Schemas["CreateWorkOrder"];
export type PeripheralUpdateWorkOrder = Schemas["UpdateWorkOrder"];
export type PeripheralTestDataRecord = Schemas["TestDataRecord"];
export type PeripheralTestDataRecordPage = Schemas["TestDataRecordPage"];
export type PeripheralCreateTestDataRecord = Schemas["CreateTestDataRecord"];
export type PeripheralUpdateTestDataRecord = Schemas["UpdateTestDataRecord"];
export type PeripheralDeletedResource = Schemas["DeletedResource"];
type PeripheralMutationResult = PeripheralTask | PeripheralScenarioState | PeripheralWorkOrder |
PeripheralTestDataRecord | PeripheralDeletedResource;
const API_PREFIX = "/api/sample/v1";
export class PeripheralSampleClient {
private readonly baseUrl = env.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL;
private projectIdPromise: Promise<string> | null = null;
private readonly taskProjection = new Map<string, PeripheralTask>();
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
private scenarioProjection: PeripheralScenarioState | null = null;
constructor(private readonly getAccessToken?: AccessTokenProvider) {}
get configured() {
return Boolean(this.baseUrl);
}
getContext(signal?: AbortSignal) { return this.get<PeripheralContext>("/context", signal); }
getScenario(signal?: AbortSignal) { return this.getScenarioProjected("/scenario", signal); }
listPoints(signal?: AbortSignal) { return this.get<PeripheralPointPage>("/monitoring/points?limit=500", signal); }
listAlerts(signal?: AbortSignal) { return this.get<PeripheralAlertPage>("/monitoring/alerts", signal); }
getDemoData(signal?: AbortSignal) { return this.get<PeripheralDemoDataset>("/demo-data", signal); }
listWorkOrders(signal?: AbortSignal) { return this.get<PeripheralWorkOrderPage>("/work-orders?limit=100", signal); }
listTestData(signal?: AbortSignal) { return this.get<PeripheralTestDataRecordPage>("/test-data?limit=100", signal); }
listObservations(pointId: string, start: string, end: string, signal?: AbortSignal) {
const query = new URLSearchParams({ point_id: pointId, start, end });
return this.get<PeripheralObservations>(`/monitoring/observations?${query}`, signal);
}
async listTasks(cursor?: string | null, signal?: AbortSignal) {
const query = new URLSearchParams({ limit: "20" });
if (cursor) query.set("cursor", cursor);
const page = await this.get<PeripheralTaskPage>(`/tasks?${query}`, signal);
return { ...page, items: page.items.map((task) => this.projectTask(task)) };
}
getTask(taskId: string, signal?: AbortSignal) {
return this.getTaskProjected(`/tasks/${encodeURIComponent(taskId)}`, signal);
}
async getReceipt(requestId: string, signal?: AbortSignal) {
const receipt = await this.get<Schemas["Receipt"]>(
`/requests/${encodeURIComponent(requestId)}`, signal
);
const result = this.projectResult(receipt.result);
for (const [path, pending] of this.pendingCommands) {
if (pending.requestId === requestId) this.pendingCommands.delete(path);
}
return result;
}
setScenario(scenario: PeripheralScenario, signal?: AbortSignal) {
return this.postRecoverable<PeripheralScenarioState>("/scenario", {
request_id: crypto.randomUUID(), scenario
}, signal);
}
createTask(input: Omit<PeripheralCreateTask, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>("/tasks", {
...input, request_id: crypto.randomUUID()
}, signal);
}
transition(taskId: string, input: Omit<PeripheralTransition, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>(
`/tasks/${encodeURIComponent(taskId)}/transitions`,
{ ...input, request_id: crypto.randomUUID() }, signal
);
}
addFeedback(taskId: string, input: Omit<PeripheralAddFeedback, "request_id" | "feedback_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>(
`/tasks/${encodeURIComponent(taskId)}/feedback`,
{ ...input, request_id: crypto.randomUUID(), feedback_id: crypto.randomUUID() }, signal
);
}
createWorkOrder(input: Omit<PeripheralCreateWorkOrder, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralWorkOrder>("/work-orders", {
...input, request_id: crypto.randomUUID()
}, signal);
}
updateWorkOrder(workOrderId: string, input: Omit<PeripheralUpdateWorkOrder, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralWorkOrder>(
`/work-orders/${encodeURIComponent(workOrderId)}`,
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
);
}
deleteWorkOrder(workOrderId: string, expectedRevision: number, signal?: AbortSignal) {
return this.postRecoverable<PeripheralDeletedResource>(
`/work-orders/${encodeURIComponent(workOrderId)}`,
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
);
}
createTestData(input: Omit<PeripheralCreateTestDataRecord, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTestDataRecord>("/test-data", {
...input, request_id: crypto.randomUUID()
}, signal);
}
updateTestData(recordId: string, input: Omit<PeripheralUpdateTestDataRecord, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTestDataRecord>(
`/test-data/${encodeURIComponent(recordId)}`,
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
);
}
deleteTestData(recordId: string, expectedRevision: number, signal?: AbortSignal) {
return this.postRecoverable<PeripheralDeletedResource>(
`/test-data/${encodeURIComponent(recordId)}`,
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
);
}
private async getTaskProjected(path: string, signal?: AbortSignal) {
return this.projectTask(await this.get<PeripheralTask>(path, signal));
}
private async getScenarioProjected(path: string, signal?: AbortSignal) {
return this.projectScenario(await this.get<PeripheralScenarioState>(path, signal));
}
private async postRecoverable<T extends PeripheralMutationResult>(
path: string,
payload: { request_id: string } & Record<string, unknown>,
signal?: AbortSignal,
method: "POST" | "PATCH" | "DELETE" = "POST"
): Promise<T> {
const commandPath = `${method}:${path}`;
const serialized = JSON.stringify(payload);
const fingerprint = JSON.stringify(
Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "request_id"))
);
const pending = this.pendingCommands.get(commandPath);
if (pending) {
if (pending.payload !== fingerprint) {
throw new PeripheralSampleError(
"PENDING_COMMAND_MISMATCH",
`此操作仍有未确认请求 ${pending.requestId},新内容未发送。请先查询原回执。`,
"unknown",
pending.requestId
);
}
return this.recoverReceipt<T>(commandPath, pending.requestId, signal);
}
const headers = await this.prepareHeaders(signal, true);
try {
const result = await this.requestWithHeaders<T>(path, {
method, body: serialized, signal, headers
}, true);
return this.projectResult(result) as T;
} catch (error) {
if (!(error instanceof PeripheralSampleError) || error.effect !== "unknown") throw error;
this.pendingCommands.set(commandPath, { requestId: payload.request_id, payload: fingerprint });
return this.recoverReceipt<T>(commandPath, payload.request_id, signal);
}
}
private async recoverReceipt<T extends PeripheralMutationResult>(
commandPath: string,
requestId: string,
signal?: AbortSignal
): Promise<T> {
try {
const receipt = await this.get<Schemas["Receipt"]>(
`/requests/${encodeURIComponent(requestId)}`, signal
);
this.pendingCommands.delete(commandPath);
return this.projectResult(receipt.result) as T;
} catch (receiptError) {
throw new PeripheralSampleError(
"UNKNOWN_OUTCOME",
`提交结果未知,未自动重试。再次操作只会查询原请求 ${requestId} 的回执。`,
"unknown",
requestId,
{ cause: receiptError }
);
}
}
private get<T>(path: string, signal?: AbortSignal) {
return this.request<T>(path, { signal });
}
private async request<T>(path: string, init: RequestInit): Promise<T> {
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
const headers = await this.prepareHeaders(init.signal ?? undefined, Boolean(init.body));
return this.requestWithHeaders<T>(path, { ...init, headers }, false);
}
private async requestWithHeaders<T>(path: string, init: RequestInit, write: boolean): Promise<T> {
let response: Response;
try {
response = await fetch(`${this.baseUrl}${API_PREFIX}${path}`, init);
} catch (error) {
if (!write) throw error;
throw new PeripheralSampleError("NETWORK_UNKNOWN", "连接中断,提交是否生效未知", "unknown", null, { cause: error });
}
let payload: unknown;
try {
payload = await response.json();
} catch (error) {
if (write) throw new PeripheralSampleError("INVALID_RESPONSE", "响应无法解析,提交是否生效未知", "unknown", null, { cause: error });
throw new PeripheralSampleError("INVALID_RESPONSE", "外围样板响应无法解析", "none", null, { cause: error });
}
if (!response.ok) {
const detail = asSampleError(payload);
throw new PeripheralSampleError(
detail?.code ?? `HTTP_${response.status}`,
detail?.message ?? `外围样板请求失败:HTTP ${response.status}`,
detail?.effect ?? (write && response.status >= 500 ? "unknown" : "none"),
detail?.request_id ?? null
);
}
const expectedProjectId = new Headers(init.headers).get("X-Project-Id");
if (expectedProjectId && !hasOnlyExpectedProjectIds(payload, expectedProjectId)) {
throw invalidResponseError(write, "外围样板响应属于其他项目");
}
if (!write && !isRecognizedReadPayload(path, payload)) {
throw invalidResponseError(false, "外围样板响应结构无效");
}
return payload as T;
}
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
const projectId = await this.resolveProjectId(signal);
const accessToken = await this.getAccessToken?.();
return createWorkbenchHeaders(accessToken, projectId, json);
}
private resolveProjectId(signal?: AbortSignal) {
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken, signal)
.then(({ project }) => project.project_id)
.catch((error) => { this.projectIdPromise = null; throw error; });
return this.projectIdPromise;
}
private projectResult(result: PeripheralMutationResult) {
if (isTask(result)) return this.projectTask(result);
if (isScenarioState(result)) return this.projectScenario(result);
if (isManagedResult(result)) return result;
throw new PeripheralSampleError("INVALID_RESPONSE", "响应缺少可识别的样板资源,提交是否生效未知", "unknown", null);
}
private projectScenario(next: PeripheralScenarioState) {
if (this.scenarioProjection && next.revision < this.scenarioProjection.revision) {
return this.scenarioProjection;
}
this.scenarioProjection = next;
return next;
}
private projectTask(next: PeripheralTask) {
const current = this.taskProjection.get(next.task_id);
if (current && next.revision < current.revision) return current;
const feedback = [...new Map(next.feedback.map((item) => [item.feedback_id, item])).values()];
const projected = { ...next, feedback };
this.taskProjection.set(next.task_id, projected);
return projected;
}
}
function isTask(value: unknown): value is PeripheralTask {
return Boolean(value && typeof value === "object" &&
typeof (value as Partial<PeripheralTask>).task_id === "string" &&
typeof (value as Partial<PeripheralTask>).revision === "number" &&
Array.isArray((value as Partial<PeripheralTask>).feedback));
}
function isScenarioState(value: unknown): value is PeripheralScenarioState {
if (!value || typeof value !== "object") return false;
const item = value as Partial<PeripheralScenarioState>;
return typeof item.revision === "number" && typeof item.scenario === "string" &&
["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"].includes(item.scenario);
}
function isManagedResult(value: unknown): value is PeripheralWorkOrder | PeripheralTestDataRecord | PeripheralDeletedResource {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
if (item.deleted === true) return typeof item.resource_id === "string" &&
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
return (typeof item.work_order_id === "string" || typeof item.record_id === "string") &&
typeof item.revision === "number" && item.origin === "synthetic";
}
export class PeripheralSampleError extends Error {
constructor(
readonly code: string,
message: string,
readonly effect: "none" | "unknown",
readonly requestId: string | null,
options?: ErrorOptions
) {
super(message, options);
this.name = "PeripheralSampleError";
}
}
function asSampleError(value: unknown): Schemas["SampleError"] | null {
if (!value || typeof value !== "object") return null;
const item = value as Partial<Schemas["SampleError"]>;
if (typeof item.code !== "string" || typeof item.message !== "string") return null;
if (item.effect !== "none" && item.effect !== "unknown") return null;
return { code: item.code, message: item.message, effect: item.effect, request_id: item.request_id ?? null };
}
function invalidResponseError(write: boolean, message: string) {
return new PeripheralSampleError(
"INVALID_RESPONSE",
write ? `${message},提交是否生效未知` : message,
write ? "unknown" : "none",
null
);
}
function hasOnlyExpectedProjectIds(value: unknown, expectedProjectId: string): boolean {
if (Array.isArray(value)) {
return value.every((item) => hasOnlyExpectedProjectIds(item, expectedProjectId));
}
if (!value || typeof value !== "object") return true;
const item = value as Record<string, unknown>;
if ("project_id" in item && item.project_id !== expectedProjectId) return false;
return Object.values(item).every((child) => hasOnlyExpectedProjectIds(child, expectedProjectId));
}
function isRecognizedReadPayload(path: string, value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
if (path === "/context") {
return typeof item.project_id === "string"
&& item.protocol_version === "tjwater-peripheral-sample@1";
}
if (path === "/scenario") return isScenarioState(value);
if (path.startsWith("/requests/")) {
return typeof item.request_id === "string" && "result" in item;
}
if (path.startsWith("/tasks?")) {
return isPageOf(value, isTask) && (item.next_cursor === null || typeof item.next_cursor === "string");
}
if (path.startsWith("/tasks/")) return isTask(value);
if (path.startsWith("/monitoring/points")) return isPageOf(value, isPoint);
if (path.startsWith("/monitoring/alerts")) return isPageOf(value, isAlert);
if (path.startsWith("/monitoring/observations")) {
return isPageOf(value, isObservation) && typeof item.truncated === "boolean";
}
if (path === "/demo-data") {
return item.origin === "synthetic"
&& typeof item.generator_version === "string"
&& Array.isArray(item.points)
&& Array.isArray(item.observations);
}
if (path.startsWith("/work-orders")) {
return isPageOf(value, (entry) => isManagedResult(entry) && "work_order_id" in entry);
}
if (path.startsWith("/test-data")) {
return isPageOf(value, (entry) => isManagedResult(entry) && "record_id" in entry);
}
return false;
}
function isPageOf(
value: unknown,
predicate: (item: Record<string, unknown>) => boolean
): boolean {
if (!value || typeof value !== "object") return false;
const items = (value as Record<string, unknown>).items;
return Array.isArray(items) && items.every(
(item) => Boolean(item && typeof item === "object" && predicate(item as Record<string, unknown>))
);
}
function isPoint(item: Record<string, unknown>) {
return typeof item.point_id === "string" && typeof item.label === "string";
}
function isAlert(item: Record<string, unknown>) {
return typeof item.alert_id === "string" && typeof item.title === "string";
}
function isObservation(item: Record<string, unknown>) {
return typeof item.record_id === "string" && typeof item.point_id === "string";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T08:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T16:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: 8
}));
vi.mock("@/shared/config/env", () => ({ env: runtime }));
import { configuredHistoryWindow } from "./history-window";
describe("peripheral history window", () => {
beforeEach(() => {
runtime.TJWATER_PERIPHERAL_SAMPLE_DEMO_START = "2026-04-30T08:00:00Z";
runtime.TJWATER_PERIPHERAL_SAMPLE_DEMO_END = "2026-04-30T16:00:00Z";
runtime.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS = 8;
});
it("uses the configured fixed window in demo mode", () => {
expect(configuredHistoryWindow("demo", new Date("2030-01-01T00:00:00Z"))).toEqual({
start: "2026-04-30T08:00",
end: "2026-04-30T16:00"
});
});
it("derives a bounded UTC window from the clock in recent mode", () => {
runtime.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS = 6;
expect(configuredHistoryWindow("recent", new Date("2026-09-18T10:42:59Z"))).toEqual({
start: "2026-09-18T04:42",
end: "2026-09-18T10:42"
});
});
});
@@ -0,0 +1,29 @@
import { env } from "@/shared/config/env";
export type HistoryTimeMode = "demo" | "recent" | "custom";
export type HistoryWindow = {
start: string;
end: string;
};
export function configuredHistoryWindow(
mode: Exclude<HistoryTimeMode, "custom">,
now = new Date()
): HistoryWindow {
if (mode === "demo") {
return {
start: toUtcMinute(new Date(env.TJWATER_PERIPHERAL_SAMPLE_DEMO_START)),
end: toUtcMinute(new Date(env.TJWATER_PERIPHERAL_SAMPLE_DEMO_END))
};
}
const end = new Date(Math.floor(now.getTime() / 60_000) * 60_000);
const start = new Date(
end.getTime() - env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS * 60 * 60 * 1000
);
return { start: toUtcMinute(start), end: toUtcMinute(end) };
}
function toUtcMinute(value: Date) {
return value.toISOString().slice(0, 16);
}
@@ -0,0 +1,386 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
Ban,
CalendarClock,
Check,
CircleDot,
ClipboardList,
Database,
GitBranch,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Trash2,
Wrench,
X
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type {
PeripheralContext,
PeripheralSampleClient,
PeripheralTestDataRecord,
PeripheralWorkOrder
} from "./client";
type ResourceView = "orders" | "data";
type Inventory = { workOrders: number; testData: number };
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
onInventoryChange?: (inventory: Inventory) => void;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
const [view, setView] = useState<ResourceView>("orders");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setBusy("refresh");
setError(null);
const [orderResult, dataResult] = await Promise.allSettled([
capabilities.work_orders === "available"
? client.listWorkOrders()
: Promise.resolve({ items: [], next_cursor: null }),
capabilities.test_data === "available"
? client.listTestData()
: Promise.resolve({ items: [], next_cursor: null })
]);
if (orderResult.status === "fulfilled") setOrders(orderResult.value.items);
if (dataResult.status === "fulfilled") setRecords(dataResult.value.items);
const failed = [orderResult, dataResult].find((result) => result.status === "rejected");
if (failed?.status === "rejected") setError(message(failed.reason));
setBusy(null);
}, [capabilities.test_data, capabilities.work_orders, client]);
useEffect(() => { void refresh(); }, [refresh]);
useEffect(() => {
onInventoryChange?.({ workOrders: orders.length, testData: records.length });
}, [onInventoryChange, orders.length, records.length]);
const mutate = async (key: string, operation: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await operation();
await refresh();
} catch (nextError) {
setError(message(nextError));
setBusy(null);
}
};
return <WorkbenchSection
title="外围任务编排"
description="一个任务包含多个子任务,每个子任务按派发、执行、复令推进。"
icon={<GitBranch size={15} />}
tone="teal"
bodyClassName="space-y-3"
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
>
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null}
{capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<GitBranch size={13} />} onClick={() => setView("orders")}>任务结构</ResourceSwitch>
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
</div>
{view === "orders" ? (
capabilities.work_orders === "available"
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
: null
) : (
capabilities.test_data === "available"
? <TestData records={records} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
: null
)}
</WorkbenchSection>;
}
function ResourceSwitch({ active, count, icon, onClick, children }: {
active: boolean;
count: number;
icon: ReactNode;
onClick: () => void;
children: ReactNode;
}) {
return <button
type="button"
aria-pressed={active}
onClick={onClick}
className={cn(
"flex min-h-9 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-[background-color,color,box-shadow] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
active ? "bg-white text-slate-900 shadow-xs" : "text-slate-500 [@media(hover:hover)]:hover:text-slate-800"
)}
>
{icon}<span className="truncate">{children}</span><span className="tabular-nums text-slate-400">{count}</span>
</button>;
}
function StoragePending({ resource, tables }: { resource: string; tables: string }) {
return <WorkbenchNotice tone="warning" title={`${resource}存储待供给`}>
缺少 peripheral_sample.{tables.replace(" / ", " 或 peripheral_sample.")};原有监测回放和演练仍可使用。
</WorkbenchNotice>;
}
function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps & {
orders: PeripheralWorkOrder[];
}) {
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
const clear = () => {
setEditing(null);
setEditorOpen(false);
setTitle("");
setSummary("");
setStatus("draft");
setPriority("normal");
};
const create = () => {
clear();
setEditorOpen(true);
};
const edit = (item: PeripheralWorkOrder) => {
setEditing(item);
setEditorOpen(true);
setTitle(item.title);
setSummary(item.summary);
setStatus(item.status);
setPriority(item.priority);
};
return <div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">任务层级</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">管理主任务下的子任务及其阶段状态。</p></div>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增子任务</Button>
</div>
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
<EditorHeading title={editing ? "编辑子任务" : "新增子任务"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="子任务状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null });
clear();
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建子任务"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null}
<TaskHierarchy
orders={orders}
canWrite={canWrite}
busy={busy}
onEdit={edit}
onDelete={(item) => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</div>;
}
function TaskHierarchy({ orders, canWrite, busy, onEdit, onDelete }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
onEdit: (item: PeripheralWorkOrder) => void;
onDelete: (item: PeripheralWorkOrder) => void;
}) {
const completed = orders.filter((item) => item.status === "completed").length;
const parentStatus = taskStatus(orders);
return <section aria-label="任务层级" className="mt-3 overflow-hidden rounded-xl bg-slate-50/80 ring-1 ring-inset ring-slate-200">
<header className="border-b border-cyan-100 bg-cyan-50/70 px-3.5 py-3 text-slate-900">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-xs ring-1 ring-inset ring-cyan-100"><ClipboardList size={16} /></span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-semibold text-cyan-700">任务</span><StatusBadge tone={parentStatus.tone}>{parentStatus.label}</StatusBadge></div>
<h5 className="mt-1 truncate text-sm font-semibold">外围合成数据核查</h5>
<p className="mt-1 text-[10px] text-slate-500"><span className="font-mono">DEMO-WO-001</span> · {orders.length} 个子任务 · {completed}/{orders.length} 完成</p>
</div>
</div>
</header>
{orders.length ? <ol className="relative space-y-2 p-3 before:absolute before:bottom-8 before:left-[25px] before:top-3 before:w-px before:bg-cyan-200">
{orders.map((item, index) => <li key={item.work_order_id} className="relative pl-7">
<span className="absolute left-0 top-3 z-10 grid size-5 place-items-center rounded-full bg-white text-[10px] font-semibold tabular-nums text-cyan-800 ring-1 ring-inset ring-cyan-300">{index + 1}</span>
<SubtaskCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
</li>)}
</ol> : <div className="p-3"><WorkbenchEmptyState icon={<Wrench size={15} />} title="当前任务还没有子任务" description="新增子任务后,可在这里查看每项任务的派发、执行和复令状态。" /></div>}
</section>;
}
function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
item: PeripheralWorkOrder;
index: number;
canWrite: boolean;
busy: string | null;
onEdit: () => void;
onDelete: () => void;
}) {
return <article className="rounded-xl bg-white p-3 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">子任务 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge></div>
<h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p>
</div>
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
</div>
<StageProgress status={item.status} title={item.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span><span>r{item.revision}</span>{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
</div>
</article>;
}
type StageStatus = "done" | "current" | "pending" | "skipped";
function StageProgress({ status, title }: { status: PeripheralWorkOrder["status"]; title: string }) {
const statuses = stageStatuses(status);
const stages = ["派发", "执行", "复令"];
return <div className="mt-3 rounded-lg bg-slate-50 px-2.5 py-2" aria-label={`子任务阶段 ${title}`}>
<p className="mb-2 text-[10px] font-semibold text-slate-500">子任务阶段</p>
<ol className="grid grid-cols-3">
{stages.map((stage, index) => {
const stageStatus = statuses[index];
return <li key={stage} className="relative min-w-0 text-center">
{index < stages.length - 1 ? <span className={cn("absolute left-[calc(50%+9px)] right-[calc(-50%+9px)] top-2 h-px", stageStatus === "done" ? "bg-cyan-400" : "bg-slate-300")} aria-hidden="true" /> : null}
<span className={cn(
"relative z-10 mx-auto grid size-4 place-items-center rounded-full text-[9px] font-semibold",
stageStatus === "done" && "bg-cyan-700 text-white",
stageStatus === "current" && "bg-white text-cyan-700 ring-2 ring-inset ring-cyan-500",
stageStatus === "pending" && "bg-white text-slate-400 ring-1 ring-inset ring-slate-300",
stageStatus === "skipped" && "bg-slate-200 text-slate-500"
)}>{stageStatus === "done" ? <Check size={10} /> : stageStatus === "current" ? <CircleDot size={10} /> : stageStatus === "skipped" ? <Ban size={9} /> : index + 1}</span>
<p className="mt-1 truncate text-[10px] font-semibold text-slate-700">{stage}</p>
<p className={cn("truncate text-[9px]", stageStatus === "current" ? "font-semibold text-cyan-700" : stageStatus === "done" ? "text-cyan-700" : "text-slate-400")}>{stageStatusLabel(stageStatus)}</p>
</li>;
})}
</ol>
</div>;
}
function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
records: PeripheralTestDataRecord[];
}) {
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PeripheralTestDataRecord | null>(null);
const [label, setLabel] = useState("");
const [pointId, setPointId] = useState("DEMO-P001");
const [observedAt, setObservedAt] = useState(toLocalInput(new Date()));
const [value, setValue] = useState("");
const [unit, setUnit] = useState("m");
const [quality, setQuality] = useState<PeripheralTestDataRecord["quality"]>("valid");
const clear = () => {
setEditing(null);
setEditorOpen(false);
setLabel("");
setPointId("DEMO-P001");
setObservedAt(toLocalInput(new Date()));
setValue("");
setUnit("m");
setQuality("valid");
};
const create = () => {
clear();
setEditorOpen(true);
};
const edit = (item: PeripheralTestDataRecord) => {
setEditing(item);
setEditorOpen(true);
setLabel(item.label);
setPointId(item.point_id);
setObservedAt(toLocalInput(new Date(item.observed_at)));
setValue(item.value === null ? "" : String(item.value));
setUnit(item.unit);
setQuality(item.quality);
};
return <div>
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
<div className="mt-2"><DateTimeField label="观测时间" value={observedAt} onChange={setObservedAt} disabled={!canWrite || Boolean(busy)} /></div>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">数值<Input className="mt-1 h-9" inputMode="decimal" value={value} onChange={(event) => setValue(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><FieldSelect label="质量" value={quality} onValueChange={(next) => setQuality(next as typeof quality)} disabled={!canWrite || Boolean(busy)} options={[{ value: "valid", label: "有效" }, { value: "unknown", label: "未知" }, { value: "missing", label: "缺失" }]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !label.trim() || !pointId.trim() || !unit.trim() || (value !== "" && !Number.isFinite(Number(value)))} onClick={() => void mutate("save-data", async () => {
const base = { label: label.trim(), point_id: pointId.trim(), observed_at: new Date(observedAt).toISOString(), value: value === "" ? null : Number(value), unit: unit.trim(), quality, notes: "外围样板手动测试数据" };
if (editing) await client.updateTestData(editing.record_id, { expected_revision: editing.revision, clear_value: false, ...base });
else await client.createTestData(base);
clear();
})}>{busy === "save-data" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建数据"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null}
<div className="mt-3 space-y-2">
{records.length ? records.map((item) => <article key={item.record_id} className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200"><div className="flex items-start gap-2"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-1.5"><p className="truncate text-xs font-semibold text-slate-800">{item.label}</p><StatusBadge tone={qualityTone(item.quality)}>{qualityLabel(item.quality)}</StatusBadge></div><p className="mt-1 text-[11px] text-slate-500"><span className="font-medium text-slate-700">{item.point_id}</span> · <span className="tabular-nums">{item.value ?? "缺失"} {item.unit}</span> · r{item.revision}</p><p className="mt-1 text-[10px] text-slate-400">{formatTimestamp(item.observed_at)}</p></div><div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => edit(item)}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => void mutate(`delete-data-${item.record_id}`, async () => { await client.deleteTestData(item.record_id, item.revision); if (editing?.record_id === item.record_id) clear(); })}><Trash2 size={14} /></Button></div></div></article>) : <WorkbenchEmptyState icon={<Database size={15} />} title="暂无测试数据" description="新增记录后,可验证测点读数、缺失值和质量状态。" />}
</div>
</div>;
}
function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAdd }: {
title: string;
description: string;
actionLabel: string;
canWrite: boolean;
busy: string | null;
onAdd: () => void;
}) {
return <div className="flex items-start gap-3">
<div className="min-w-0 flex-1"><h4 className="text-xs font-semibold text-slate-800">{title}</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={onAdd}><Plus size={13} />{actionLabel}</Button>
</div>;
}
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-cyan-950">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
}
type ResourceProps = {
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
};
function toLocalInput(value: Date) { const offset = value.getTimezoneOffset() * 60_000; return new Date(value.getTime() - offset).toISOString().slice(0, 16); }
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC").slice(0, 19); }
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
function taskStatus(orders: PeripheralWorkOrder[]): { label: string; tone: StatusTone } {
if (!orders.length) return { label: "待编排", tone: "neutral" };
if (orders.every((item) => item.status === "completed")) return { label: "已完成", tone: "success" };
if (orders.every((item) => item.status === "cancelled")) return { label: "已取消", tone: "danger" };
if (orders.some((item) => item.status === "in_progress")) return { label: "进行中", tone: "info" };
return { label: "待处理", tone: "warning" };
}
function stageStatuses(status: PeripheralWorkOrder["status"]): [StageStatus, StageStatus, StageStatus] {
if (status === "completed") return ["done", "done", "done"];
if (status === "in_progress") return ["done", "current", "pending"];
if (status === "open") return ["current", "pending", "pending"];
if (status === "cancelled") return ["skipped", "skipped", "skipped"];
return ["pending", "pending", "pending"];
}
function stageStatusLabel(status: StageStatus) { return ({ done: "已完成", current: "当前阶段", pending: "待开始", skipped: "已跳过" } satisfies Record<StageStatus, string>)[status]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; }
function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; }
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CircleGauge, Database, History, LoaderCircle, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { Button } from "@/shared/ui/button";
import { FieldSelect } from "@/shared/ui/field-select";
import { Disclosure } from "@/shared/ui/disclosure";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { cn } from "@/shared/ui/cn";
import { StatusBadge } from "@/shared/ui/status";
import { env } from "@/shared/config/env";
import { SyntheticDemoSection } from "./synthetic-demo-section";
import { ManagedTestResources } from "./managed-test-resources";
import { configuredHistoryWindow, type HistoryTimeMode } from "./history-window";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import {
PeripheralSampleClient,
PeripheralSampleError,
type PeripheralContext,
type PeripheralObservations,
type PeripheralPointPage,
type PeripheralScenario
} from "./client";
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
open: boolean;
onClose: () => void;
getAccessToken?: AccessTokenProvider;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
const initialHistory = useRef(configuredHistoryWindow(env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE));
const [context, setContext] = useState<PeripheralContext | null>(null);
const [scenario, setScenario] = useState<PeripheralScenario>("normal");
const [monitoring, setMonitoring] = useState("未读取");
const [points, setPoints] = useState<PeripheralPointPage["items"]>([]);
const [pointId, setPointId] = useState("");
const [historyMode, setHistoryMode] = useState<HistoryTimeMode>(
env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE
);
const [historyStart, setHistoryStart] = useState(initialHistory.current.start);
const [historyEnd, setHistoryEnd] = useState(initialHistory.current.end);
const [observations, setObservations] = useState<PeripheralObservations | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [receiptRequestId, setReceiptRequestId] = useState("");
const [inventory, setInventory] = useState({ workOrders: 0, testData: 0 });
const refresh = useCallback(async () => {
if (!client.configured) return;
setBusy("refresh"); setError(null);
const [contextResult, scenarioResult, pointResult, alertResult] = await Promise.allSettled([
client.getContext(), client.getScenario(), client.listPoints(), client.listAlerts()
]);
if (contextResult.status === "fulfilled") setContext(contextResult.value);
if (scenarioResult.status === "fulfilled") setScenario(scenarioResult.value.scenario);
if (pointResult.status === "fulfilled") {
setPoints(pointResult.value.items);
setPointId((current) => current || pointResult.value.items[0]?.point_id || "");
}
if (pointResult.status === "fulfilled" && alertResult.status === "fulfilled") {
const alertText = alertResult.value.availability === "not_configured"
? "告警来源未配置"
: `${alertResult.value.items.length} 条告警`;
setMonitoring(`${pointResult.value.items.length} 个测点 · ${alertText} · ${originLabel(pointResult.value.origin)}`);
} else {
setMonitoring("监测来源当前不可用,可切回正常场景后刷新");
}
const firstFailure = [contextResult, scenarioResult].find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") setError(errorMessage(firstFailure.reason));
setBusy(null);
}, [client]);
useEffect(() => { if (open) void refresh(); }, [open, refresh]);
if (!open) return null;
return (
<aside aria-label="外围样板管理" className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(820px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]">
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4 sm:px-5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white"><CircleGauge size={17} /></span>
<div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">外围样板管理</h2><p className="text-xs text-slate-500">演练上下文、监测回放与预约工单投影</p></div>
<Button size="icon" variant="ghost" aria-label="刷新外围样板" onClick={() => void refresh()} disabled={Boolean(busy)}><RefreshCw size={16} className={cn(busy === "refresh" && "animate-spin")} /></Button>
<Button size="icon" variant="ghost" aria-label="关闭外围样板" onClick={onClose}><X size={17} /></Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
<div className="mb-3 grid gap-2 sm:grid-cols-2">
<WorkbenchNotice tone="info" title="合成演练环境">{DISCLAIMER}</WorkbenchNotice>
{!client.configured ? <WorkbenchNotice tone="warning">未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接任何外围服务。</WorkbenchNotice> : null}
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{client.configured && !context ? <WorkbenchNotice role="status" tone="neutral">正在核验项目授权与样板服务上下文。</WorkbenchNotice> : null}
</div>
<SampleOverview
configured={client.configured}
context={context}
points={points.length}
scenario={scenario}
resources={inventory.workOrders + inventory.testData}
/>
{context ? (
<div className="mt-3 grid items-start gap-3 md:grid-cols-[minmax(250px,0.78fr)_minmax(390px,1.22fr)]">
<div className="min-w-0 space-y-3">
<WorkbenchSection title="连接与演练场景" description={monitoring}
icon={<RadioTower size={15} />} tone="teal" meta={<StatusBadge tone={context.can_write ? "info" : "neutral"}>{context.can_write ? "可写样板" : "只读"}</StatusBadge>}>
<FieldSelect label="场景" value={scenario} disabled={!context.can_write || Boolean(busy)} options={context.scenarios.map((item) => ({ value: item, label: scenarioLabel(item) }))} onValueChange={async (value) => {
const next = value as PeripheralScenario;
setBusy("scenario"); setError(null);
try { setScenario((await client.setScenario(next)).scenario); await refresh(); }
catch (nextError) { setError(errorMessage(nextError)); }
finally { setBusy(null); }
}} />
</WorkbenchSection>
<WorkbenchSection title="历史监测回放"
description={`UTC 历史窗口 · ${historyMode === "demo" ? "固定演示时段" : historyMode === "recent" ? `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` : "自定义时段"}。仅查询外围数据。`}
icon={<History size={15} />} tone="teal">
<FieldSelect label="时间模式" value={historyMode} options={[
{ value: "demo", label: "演示历史窗口" },
{ value: "recent", label: `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` },
{ value: "custom", label: "自定义窗口" }
]} onValueChange={(value) => {
const next = value as HistoryTimeMode;
setHistoryMode(next); setObservations(null);
if (next !== "custom") {
const window = configuredHistoryWindow(next);
setHistoryStart(window.start); setHistoryEnd(window.end);
}
}} />
<FieldSelect className="mt-3" label="测点" value={pointId} placeholder="选择测点" options={points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} onValueChange={(value) => { setPointId(value); setObservations(null); }} />
<div className="mt-2 grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1">
<DateTimeField label="开始(UTC)" value={historyStart} onChange={(value) => { setHistoryStart(value); setHistoryMode("custom"); setObservations(null); }} />
<DateTimeField label="结束(UTC)" value={historyEnd} onChange={(value) => { setHistoryEnd(value); setHistoryMode("custom"); setObservations(null); }} />
</div>
<Button variant="outline" className="mt-2 w-full" disabled={!pointId || Boolean(busy)} onClick={async () => {
setBusy("observations"); setError(null);
try { setObservations(await client.listObservations(pointId, `${historyStart}:00Z`, `${historyEnd}:00Z`)); }
catch (nextError) { setError(errorMessage(nextError)); }
finally { setBusy(null); }
}}>{busy === "observations" ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}读取历史读数</Button>
{observations ? <ObservationTable observations={observations} /> : null}
</WorkbenchSection>
</div>
<div className="min-w-0 space-y-3">
<ManagedTestResources
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
onInventoryChange={setInventory}
/>
<SyntheticDemoSection client={client} />
<Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
</div>
</div>
) : null}
</div>
</aside>
);
}
function SampleOverview({ configured, context, points, scenario, resources }: {
configured: boolean;
context: PeripheralContext | null;
points: number;
scenario: PeripheralScenario;
resources: number;
}) {
const items = [
{
icon: <RadioTower size={14} />,
label: "外围服务",
value: !configured ? "未配置" : context ? "已连接" : "校验中",
detail: context?.can_write ? "可维护样板" : context ? "只读访问" : ""
},
{
icon: <History size={14} />,
label: "演练场景",
value: configured ? scenarioLabel(scenario) : "无",
detail: `${points} 个监测点`
},
{
icon: <Database size={14} />,
label: "样板资源",
value: context ? `${resources} 项` : "未读取",
detail: "工单源记录与测试数据"
}
];
return <section aria-label="外围样板概览" className="grid grid-cols-3 overflow-hidden rounded-[14px] border border-slate-200/80 bg-[var(--surface-reading)] shadow-[0_2px_8px_rgba(15,23,42,0.08)]">
{items.map((item, index) => <div key={item.label} className={cn("flex min-w-0 items-center justify-center gap-3 px-2 py-3 sm:justify-start sm:px-3.5", index > 0 && "border-l border-slate-200/80")}>
<span className="hidden size-8 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-100 sm:grid">{item.icon}</span>
<div className="min-w-0 text-center sm:text-left"><p className="truncate text-[10px] font-medium text-slate-500">{item.label}</p><p className="truncate text-sm font-semibold tabular-nums text-slate-800">{item.value}</p>{item.detail ? <p className="hidden truncate text-[10px] text-slate-400 sm:block">{item.detail}</p> : null}</div>
</div>)}
</section>;
}
function ObservationTable({ observations }: { observations: PeripheralObservations }) {
return <div className="mt-3 overflow-hidden rounded-lg bg-white ring-1 ring-slate-200"><div className="flex items-center justify-between gap-2 bg-slate-50 px-2.5 py-2 text-[11px] text-slate-600"><span>{originLabel(observations.origin)} · {observations.source_id}</span><span className="tabular-nums">{observations.truncated ? "结果已截断" : `${observations.items.length} 条`}</span></div><div className="max-h-52 overflow-auto"><table className="w-full text-left text-[11px]"><thead className="sticky top-0 bg-white text-slate-500"><tr><th className="px-2.5 py-2 font-medium">时间</th><th className="px-2 py-2 text-right font-medium">读数</th><th className="px-2 py-2 font-medium">质量</th></tr></thead><tbody>{observations.items.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{item.observed_at.replace("T", " ").replace("Z", " UTC")}</td><td className="whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums">{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>{!observations.items.length ? <p className="p-3 text-center text-xs text-slate-500">此历史窗口没有读数。</p> : null}</div></div>;
}
function errorMessage(error: unknown) { return error instanceof Error ? error.message : "外围样板请求失败"; }
function originLabel(origin: "historical_replay" | "synthetic") { return origin === "historical_replay" ? "真实项目样本回放" : "合成演练"; }
function scenarioLabel(value: PeripheralScenario) { return ({ normal: "正常", missing_data: "数据缺失", unavailable: "服务不可用", response_lost: "响应丢失", duplicate_feedback: "重复反馈", conflicting_feedback: "冲突反馈", late_state: "迟到状态" } satisfies Record<PeripheralScenario, string>)[value]; }
@@ -0,0 +1,84 @@
import { useMemo, useState } from "react";
import { Database, LoaderCircle } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { FieldSelect } from "@/shared/ui/field-select";
import { Disclosure } from "@/shared/ui/disclosure";
import {
PeripheralSampleClient,
type PeripheralDemoDataset
} from "./client";
export function SyntheticDemoSection({ client }: { client: PeripheralSampleClient }) {
const [dataset, setDataset] = useState<PeripheralDemoDataset | null>(null);
const [pointId, setPointId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const observations = useMemo(
() => dataset?.observations.filter((item) => item.point_id === pointId) ?? [],
[dataset, pointId]
);
const load = async () => {
setLoading(true);
setError(null);
try {
const next = await client.getDemoData();
setDataset(next);
setPointId((current) => current || next.points[0]?.point_id || "");
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "合成演示样本读取失败");
} finally {
setLoading(false);
}
};
return (
<Disclosure
label={<span className="flex min-w-0 items-center gap-3"><span className="grid size-7 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-800"><Database size={15} /></span><span className="min-w-0"><span className="block text-sm font-semibold text-slate-900">合成演示样本</span><span className="block truncate text-xs font-normal text-slate-500">固定种子 · 虚拟资产 · 非控制指令</span></span></span>}
className="overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&>summary]:min-h-14 [&>summary]:py-3"
>
<div className="-mx-3.5 border-t border-slate-200/80 p-3.5">
<p className="text-xs leading-5 text-slate-600">样本由外围服务从持久化数据集读取,浏览器不会生成或写入数据。所有资产、告警和运行事件均用于演示,不代表现场状态,也不是控制指令。</p>
<Button variant="outline" className="mt-3 w-full" disabled={loading} onClick={() => void load()}>
{loading ? <LoaderCircle className="animate-spin" /> : <Database />}
{dataset ? "重新读取固定样本" : "手动读取合成样本"}
</Button>
{error ? <p className="mt-2 rounded-lg bg-rose-50 p-2.5 text-xs leading-5 text-rose-900 ring-1 ring-inset ring-rose-200">{error}。真实历史回放与核查管理不受影响。</p> : null}
{dataset ? <DemoDatasetView dataset={dataset} pointId={pointId} onPointChange={setPointId} observations={observations} /> : null}
</div>
</Disclosure>
);
}
function DemoDatasetView({ dataset, pointId, onPointChange, observations }: {
dataset: PeripheralDemoDataset;
pointId: string;
onPointChange: (value: string) => void;
observations: PeripheralDemoDataset["observations"];
}) {
return <div className="mt-3 space-y-3">
<div className="rounded-xl border border-cyan-200 bg-cyan-50/65 p-3 text-xs text-cyan-950">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Meta label="性质" value="合成演示" />
<Meta label="生成器" value={dataset.generator_version} />
<Meta label="固定种子" value={dataset.seed} />
<Meta label="来源修订" value={dataset.source_revision} />
</div>
<p className="mt-2 break-words leading-5">固定窗口:{formatTimestamp(dataset.window.start)} 至 {formatTimestamp(dataset.window.end)}</p>
<p className="mt-1 leading-5">摘要:{dataset.points.length} 个虚拟测点,{dataset.observations.length} 条读数,{dataset.device_states.length} 条状态,{dataset.alerts.length} 条告警,{dataset.operations.length} 条运行事件。</p>
</div>
<FieldSelect label="虚拟测点" value={pointId} onValueChange={onPointChange} options={dataset.points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} />
<div className="max-h-48 overflow-auto rounded-lg bg-white ring-1 ring-slate-200">
<table className="w-full text-left text-[11px]"><thead className="sticky top-0 bg-white text-slate-500"><tr><th className="px-2.5 py-2 font-medium">时间</th><th className="px-2 py-2 text-right font-medium">读数</th><th className="px-2 py-2 font-medium">质量</th></tr></thead><tbody>{observations.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{formatTimestamp(item.observed_at)}</td><td className={cn("whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums", item.value === null && "text-amber-700")}>{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>
</div>
<DemoList title={`设备状态 ${dataset.device_states.length}`} items={dataset.device_states.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${item.state}`, detail: `${item.asset_type} · ${formatTimestamp(item.observed_at)}` }))} />
<DemoList title={`演示告警 ${dataset.alerts.length}`} items={dataset.alerts.map((item) => ({ id: item.alert_id, title: `${item.asset_id} · ${item.code}`, detail: `${item.message} · ${formatTimestamp(item.occurred_at)}` }))} />
<DemoList title={`运行事件 ${dataset.operations.length}`} items={dataset.operations.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${operationLabel(item.operation)}`, detail: `${item.note} · ${formatTimestamp(item.occurred_at)}` }))} />
</div>;
}
function Meta({ label, value }: { label: string; value: string }) { return <div><span className="block text-[11px] text-cyan-700">{label}</span><span className="mt-0.5 block break-all font-medium">{value}</span></div>; }
function DemoList({ title, items }: { title: string; items: Array<{ id: string; title: string; detail: string }> }) { return <Disclosure label={title} className="rounded-lg bg-white px-3 py-2 ring-1 ring-slate-200"><div className="mt-2 space-y-2">{items.map((item) => <div key={item.id} className="border-t border-slate-100 pt-2 text-[11px]"><p className="font-medium text-slate-700">{item.title}</p><p className="mt-1 leading-5 text-slate-500">{item.detail}</p></div>)}</div></Disclosure>; }
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC"); }
function operationLabel(value: PeripheralDemoDataset["operations"][number]["operation"]) { return ({ pump_start: "泵启动", pump_stop: "泵停止", valve_open: "阀门开启", valve_close: "阀门关闭" } satisfies Record<typeof value, string>)[value]; }
+55 -12
View File
@@ -16,15 +16,38 @@ export type WorkbenchScenario = {
name: string;
description: string;
status: "draft" | "active" | "review";
runId?: string;
runType?: string;
runStatus?: string;
createdAt?: string;
};
export type WorkbenchAlert = {
conditionId?: string;
id: string;
description: string;
source?: {
taskType: "scheduled_task" | "analysis_task" | "other_task";
taskId: string;
runId: string;
};
severity?: "warning" | "critical";
occurredAt?: string;
time: string;
title: string;
};
export type WorkbenchRuntimeDataSource =
| "mock"
| "backend"
| "scheduled_tasks";
export type WorkbenchAlertDataSource = "mock" | "backend";
export type WorkbenchScenarioDataSource = "mock" | "analysis_runs";
export type WorkbenchWorkOrderDataSource = "mock" | "backend" | "none";
export type WorkbenchUser = {
name: string;
role: string;
@@ -32,13 +55,21 @@ export type WorkbenchUser = {
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply";
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply" | "completed";
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
export type ScheduledConditionTaskId =
| "scada-diagnosis"
| "smart-dispatch"
| "pump-energy"
| "network-simulation";
export type ScheduledConditionRecordTaskId =
| ScheduledConditionTaskId
| "backend-schedule";
export type ScheduledConditionSolutionOption = {
id: string;
@@ -90,10 +121,11 @@ type ScheduledConditionBase<TStatus extends ScheduledConditionItemStatus> = {
export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledConditionStatus> & {
kind: "condition";
taskId: ScheduledConditionTaskId;
taskId: ScheduledConditionRecordTaskId;
sessionId: string;
evidence?: string[];
modelName?: string;
dataSource?: WorkbenchRuntimeDataSource;
analysisInsight?: ScheduledConditionAnalysisInsight;
kpis?: ScheduledConditionKpi[];
report?: ScheduledConditionReport;
@@ -103,24 +135,19 @@ export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderSt
kind: "work_order";
code: string;
source: string;
location: string;
dispatcher: string;
assignee: string;
priority: "normal" | "urgent";
replyWindowMinutes: number;
stages: ScheduledWorkOrderStage[];
replyRequirements: string[];
subtasks: ScheduledWorkOrderSubtask[];
evidence?: never;
modelName?: never;
sessionId?: never;
};
export type ScheduledWorkOrderStageId = "dispatch" | "execution" | "reply";
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending";
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending" | "blocked" | "skipped";
export type ScheduledWorkOrderStage = {
id: ScheduledWorkOrderStageId;
id: string;
sequence: number;
title: string;
status: ScheduledWorkOrderStageStatus;
owner: string;
@@ -128,6 +155,22 @@ export type ScheduledWorkOrderStage = {
description: string;
};
export type ScheduledWorkOrderSubtaskStatus = "pending" | "running" | "blocked" | "completed" | "cancelled";
export type ScheduledWorkOrderSubtask = {
id: string;
title: string;
summary: string;
status: ScheduledWorkOrderSubtaskStatus;
scheduledAt: string;
location: string;
assignee: string;
durationMinutes: number;
replyWindowMinutes: number;
stages: ScheduledWorkOrderStage[];
replyRequirements: string[];
};
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
export type MapAnnotation = {
@@ -80,15 +80,21 @@ describe("feature panel properties", () => {
node_id: "J-1",
link_id: null,
api_query_id: "query-1",
measurement_unit: "MPa",
latest_value_with_unit: "0.31 MPa",
latest_observed_at: "2026-09-17T02:29:00Z",
transmission_mode: "non_realtime",
transmission_frequency: "15min",
reliability: "high"
}).map(({ label, value }) => [label, value])).toEqual([
["设备 ID", "S-1"],
["设备类型", "压力监测"],
["最新监测值", "0.31 MPa"],
["监测时间", expect.any(String)],
["关联节点", "J-1"],
["关联连线", "暂无"],
["采集接口", "query-1"],
["计量单位", "MPa"],
["传输方式", "非实时"],
["传输频率", "15min"],
["可靠性", "高"]
@@ -111,7 +117,7 @@ describe("feature panel properties", () => {
it.each([
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
["reservoirs", ["id", "head", "pattern_id"]],
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
["scada", ["device_id", "device_type", "latest_value_with_unit", "latest_observed_at", "node_id", "link_id", "api_query_id", "measurement_unit", "transmission_mode", "transmission_frequency", "reliability"]],
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]]
] as const)("localizes configured %s panel field labels", (layer, keys) => {
@@ -1,4 +1,4 @@
import type { WaterNetworkSourceId } from "../map/sources";
import { isSourceGisSourceId, type WaterNetworkSourceId } from "../map/sources";
import { formatValue } from "./format-value";
export type LocalizedFeatureProperty = {
@@ -110,9 +110,14 @@ const PROPERTY_LABELS: Record<string, string> = {
node_id: "关联节点",
link_id: "关联连线",
api_query_id: "采集接口",
measurement_unit: "计量单位",
transmission_mode: "传输方式",
transmission_frequency: "传输频率",
reliability: "可靠性",
latest_value: "最新监测值",
latest_value_with_unit: "最新监测值",
latest_observed_at: "监测时间",
latest_value_source: "数值来源",
device_id: "设备 ID",
device_external_id: "设备编号",
device_type_id: "设备类型 ID",
@@ -124,7 +129,17 @@ const PROPERTY_LABELS: Record<string, string> = {
kind: "设备类型",
location_source: "位置来源",
site_external_id: "站点编号",
synced_at: "同步时间"
synced_at: "同步时间",
source_fid: "源要素编号",
source_object_id: "源对象编号",
source_layer_name: "源图层",
dataset_title: "数据集",
source_date: "数据日期",
monitoring_point_no: "监测点序号",
selection_method: "选点依据",
minimum_diameter: "关联管径下限",
source_analysis: "来源分析",
run_id: "分析运行编号"
};
const PROPERTY_ORDER: Record<string, number> = {
@@ -292,16 +307,47 @@ const PROPERTY_UNITS: Record<string, string> = {
流量_m3_s: "m³/s"
};
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
const FEATURE_PANEL_PROPERTY_KEYS: Partial<Record<WaterNetworkSourceId, readonly string[]>> = {
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
junctions: ["id", "elevation", "base_demand"],
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
reservoirs: ["id", "head", "pattern_id"],
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
scada: [
"device_id",
"device_type",
"latest_value_with_unit",
"latest_observed_at",
"node_id",
"link_id",
"api_query_id",
"measurement_unit",
"transmission_mode",
"transmission_frequency",
"reliability"
],
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"],
water_quality_monitoring_points: [
"id",
"monitoring_point_no",
"node_id",
"selection_method",
"minimum_diameter",
"elevation",
"source_analysis"
]
};
const SOURCE_GIS_PANEL_PROPERTY_KEYS = [
"id",
"source_layer_name",
"source_date",
"source_object_id",
"diameter",
"length",
"dataset_title"
] as const;
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
pipes: "status",
valves: "setting"
@@ -313,7 +359,7 @@ type FeatureInsightMetricDefinition = {
value?: string;
};
const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsightMetricDefinition[]> = {
const FEATURE_INSIGHT_METRICS: Partial<Record<WaterNetworkSourceId, readonly FeatureInsightMetricDefinition[]>> = {
pipes: [
{ label: "管径", key: "diameter" },
{ label: "长度", key: "length" },
@@ -339,10 +385,11 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
{ label: "Y 坐标", key: "y" }
],
scada: [
{ label: "类型", key: "device_type" },
{ label: "最新监测值", key: "latest_value_with_unit" },
{ label: "监测时间", key: "latest_observed_at" },
{ label: "关联节点", key: "node_id" },
{ label: "关联连线", key: "link_id" },
{ label: "传输方式", key: "transmission_mode" }
{ label: "类型", key: "device_type" }
],
pumps: [
{ label: "功率", key: "power" },
@@ -355,6 +402,12 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
{ label: "初始水位", key: "initial_level" },
{ label: "最低水位", key: "minimum_level" },
{ label: "最高水位", key: "maximum_level" }
],
water_quality_monitoring_points: [
{ label: "监测点序号", key: "monitoring_point_no" },
{ label: "关联节点", key: "node_id" },
{ label: "关联管径下限", key: "minimum_diameter" },
{ label: "选点依据", value: "灵敏度分析" }
]
};
@@ -371,7 +424,9 @@ export function getFeaturePanelProperties(
properties: Record<string, unknown>,
featureId?: string
): LocalizedFeatureProperty[] {
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
const keys = FEATURE_PANEL_PROPERTY_KEYS[layer]
?? (isSourceGisSourceId(layer) ? SOURCE_GIS_PANEL_PROPERTY_KEYS : ["id"]);
return keys.map((key) => {
const propertyValue = getPropertyValue(properties, key);
const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue;
@@ -397,7 +452,15 @@ export function getFeatureInsightMetrics(
layer: WaterNetworkSourceId,
properties: Record<string, unknown>
): FeatureInsightMetric[] {
return FEATURE_INSIGHT_METRICS[layer].map((metric) => ({
const definitions = FEATURE_INSIGHT_METRICS[layer]
?? (isSourceGisSourceId(layer)
? [
{ label: "源图层", key: "source_layer_name" },
{ label: "源要素", key: "source_fid" },
{ label: "数据日期", key: "source_date" }
]
: []);
return definitions.map((metric) => ({
label: metric.label,
value: metric.key
? formatFeaturePropertyValue(metric.key, getPropertyValue(properties, metric.key))
@@ -448,6 +511,24 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return value ? "在线" : "离线";
}
if (normalizedKey === "latest_observed_at" && typeof value === "string") {
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(new Date(timestamp));
}
}
if (normalizedKey === "latest_value_source" && value === "monitored_value") {
return "SCADA 原始监测值";
}
if (normalizedKey === "location_source" && value === "density_cluster_demo") {
return "密度聚类模拟位置";
}
@@ -456,6 +537,10 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return "空间均匀采样模拟位置";
}
if (normalizedKey === "selection_method" && value === "sensitivity") {
return "灵敏度分析";
}
if (normalizedKey === "kind" && typeof value === "string") {
return SCADA_KIND_LABELS[value] ?? value;
}
@@ -465,7 +550,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return `${formatValue(value)} ${unit}`;
}
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
if (["diameter", "dn", "minimum_diameter"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") {
const diameterInMillimeters = typeof value === "number" ? value : Number(value);
return Number.isFinite(diameterInMillimeters)
? `${formatValue(diameterInMillimeters)} mm`
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { GisAttributeGroup } from "../api/gis-properties-client";
import { presentGisAttributeGroup } from "./gis-attribute-presentation";
describe("GIS attribute presentation", () => {
it("prefers the actual DN100 inventory diameter and folds raw compatibility fields", () => {
const presentation = presentGisAttributeGroup(createGroup({
PIPE_D: 150,
DIAMETER: 0,
LENGTH: 91.85,
MATERIAL: "球墨管",
DEPTH_1: 0.8,
SSHX: "奉城镇",
ISABANDON: "正常",
TEMP1: "0000/00/00",
BZ: null,
GJZ: 5752299
}));
expect(presentation.datasetRole).toBe("扩展台账");
expect(presentation.summary).toEqual(expect.arrayContaining([
expect.objectContaining({ label: "管径", value: "150 mm" }),
expect.objectContaining({ label: "长度", value: "91.85 m" }),
expect.objectContaining({ label: "材质", value: "球墨管" }),
expect.objectContaining({ label: "埋深", value: "0.80 m" }),
expect.objectContaining({ label: "所属区域", value: "奉城镇" }),
expect.objectContaining({ label: "状态", value: "正常" })
]));
expect(presentation.summary.map((entry) => entry.value)).not.toContain("0 mm");
expect(presentation.usefulCount).toBe(7);
expect(presentation.rawCount).toBe(10);
expect(presentation.rawEntries).toEqual(expect.arrayContaining([
expect.objectContaining({ label: "PIPE_D", value: "150 mm" }),
expect.objectContaining({ label: "DIAMETER", value: "0 mm" })
]));
});
});
function createGroup(attributes: Record<string, unknown>): GisAttributeGroup {
return {
dataset_key: "fx_pipe_inventory_20200811",
dataset_title: "奉贤 DN100 及以上管网台账",
source_date: "2020-08-11",
layer_key: "fx_20200811_dn100_pipes",
layer_name: "DN100 及以上管线台账",
source_fid: 117,
match_method: "geometry",
match_distance_m: 0.12,
attributes
};
}
@@ -0,0 +1,211 @@
import type { GisAttributeGroup } from "../api/gis-properties-client";
import {
formatFeaturePropertyValue,
getFeaturePropertyLabel,
type LocalizedFeatureProperty
} from "./feature-properties";
type SummaryFieldDefinition = {
id: string;
label: string;
keys: readonly string[];
formatKey?: string;
rejectZero?: boolean;
};
export type GisAttributeSection = {
id: string;
label: string;
entries: LocalizedFeatureProperty[];
};
export type GisAttributePresentation = {
datasetRole: string;
description: string;
usefulCount: number;
rawCount: number;
summary: LocalizedFeatureProperty[];
sections: GisAttributeSection[];
rawEntries: LocalizedFeatureProperty[];
};
const PIPE_SUMMARY_FIELDS: readonly SummaryFieldDefinition[] = [
{ id: "diameter", label: "管径", keys: ["PIPE_D", "管径", "直径", "DIAMETER"], formatKey: "diameter", rejectZero: true },
{ id: "length", label: "长度", keys: ["LENGTH", "长度", "SHAPE_LEN", "SHAPE_LENG"], formatKey: "length", rejectZero: true },
{ id: "material", label: "材质", keys: ["MATERIAL", "材质"] },
{ id: "depth", label: "埋深", keys: ["DEPTH_1", "埋深", "DEPTH", "方顶深度"], formatKey: "max_depth", rejectZero: true },
{ id: "location", label: "道路或位置", keys: ["ST_NAME", "所在路名", "地址", "地点", "地理位置"] },
{ id: "district", label: "所属区域", keys: ["SSHX", "SSQX", "行政区", "所属区域"] },
{ id: "operator", label: "管理单位", keys: ["SSZS", "COMP_NAME", "所属公司"] },
{ id: "status", label: "状态", keys: ["ISABANDON", "状态", "ENABLED"] }
];
const GENERIC_SUMMARY_FIELDS: readonly SummaryFieldDefinition[] = [
{ id: "category", label: "设备类别", keys: ["设备类别", "SBLB_1", "SBLB"] },
{ id: "diameter", label: "口径", keys: ["直径", "干管口径", "PIPE_D", "DIAMETER"], formatKey: "diameter", rejectZero: true },
{ id: "location", label: "位置", keys: ["地址", "地点", "地理位置", "所在路名", "ST_NAME"] },
{ id: "operator", label: "所属单位", keys: ["所属公司", "COMP_NAME", "SSZS"] },
{ id: "status", label: "状态", keys: ["状态", "ISABANDON", "ENABLED"] },
{ id: "installed", label: "安装日期", keys: ["安装日期", "INS_DATE", "INP_DATE", "CDATE"] }
];
const ZERO_IS_MEANINGFUL = new Set(["enabled", "reliability"]);
const RAW_FORMAT_KEYS: Record<string, string> = {
pipe_d: "diameter",
depth_1: "max_depth"
};
const RAW_ONLY_KEYS = new Set([
"objectid",
"shape_leng",
"shape_len",
"fnode",
"tnode",
"symbol",
"angle",
"angle_1",
"wzgjz",
"kzgjz",
"gjz_1"
]);
const SECTION_ORDER = ["identity", "location", "construction", "provenance", "other"] as const;
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = {
identity: "标识与状态",
location: "位置与权属",
construction: "建设与维护",
provenance: "数据来源",
other: "其他有效属性"
};
export function presentGisAttributeGroup(
group: GisAttributeGroup
): GisAttributePresentation {
const isPipe = group.layer_key === "fx_20200423_pipes"
|| group.layer_key === "fx_20200811_dn100_pipes";
const definitions = isPipe ? PIPE_SUMMARY_FIELDS : GENERIC_SUMMARY_FIELDS;
const consumedKeys = new Set<string>();
const summary = definitions.flatMap((definition) => {
const match = findFirstUsefulAttribute(group.attributes, definition);
if (!match) return [];
consumedKeys.add(match.key);
return [{
key: definition.id,
label: definition.label,
value: formatFeaturePropertyValue(definition.formatKey ?? match.key, match.value)
}];
});
const usefulEntries = Object.entries(group.attributes)
.filter(([key, value]) => isUsefulGisValue(key, value));
if (summary.length === 0) {
usefulEntries.slice(0, 6).forEach(([key, value]) => {
consumedKeys.add(key);
summary.push(toLocalizedEntry(key, value));
});
}
const sectionEntries = new Map<string, LocalizedFeatureProperty[]>();
usefulEntries.forEach(([key, value]) => {
if (consumedKeys.has(key) || RAW_ONLY_KEYS.has(normalizeKey(key))) return;
const sectionId = classifyField(key);
const entries = sectionEntries.get(sectionId) ?? [];
entries.push(toLocalizedEntry(key, value));
sectionEntries.set(sectionId, entries);
});
return {
...getDatasetPresentation(group.layer_key),
usefulCount: usefulEntries.length,
rawCount: Object.keys(group.attributes).length,
summary,
sections: SECTION_ORDER.flatMap((id) => {
const entries = sectionEntries.get(id);
return entries?.length ? [{ id, label: SECTION_LABELS[id], entries }] : [];
}),
rawEntries: Object.entries(group.attributes).map(([key, value]) => ({
key,
label: key,
value: formatFeaturePropertyValue(RAW_FORMAT_KEYS[normalizeKey(key)] ?? key, value)
}))
};
}
function findFirstUsefulAttribute(
attributes: Record<string, unknown>,
definition: SummaryFieldDefinition
) {
for (const key of definition.keys) {
if (!(key in attributes)) continue;
const value = attributes[key];
if (!isUsefulGisValue(key, value)) continue;
if (definition.rejectZero && isZero(value)) continue;
return { key, value };
}
return null;
}
function isUsefulGisValue(key: string, value: unknown) {
if (value === null || value === undefined) return false;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || trimmed === "0000/00/00" || trimmed === "0000-00-00") return false;
}
if (isZero(value) && !ZERO_IS_MEANINGFUL.has(normalizeKey(key))) return false;
return true;
}
function isZero(value: unknown) {
return (typeof value === "number" && value === 0)
|| (typeof value === "string" && /^0+(?:\.0+)?$/.test(value.trim()));
}
function classifyField(key: string): (typeof SECTION_ORDER)[number] {
const normalized = normalizeKey(key);
if (/地址|地点|位置|路名|区域|公司|管理|所属|乡镇|行政/.test(key)
|| ["st_name", "ssqx", "sszs", "sshx", "comp_name"].includes(normalized)) {
return "location";
}
if (/日期|安装|工程|材质|口径|直径|长度|埋深|压力|接口|防腐|路面|厂家/.test(key)
|| /^(pipe_d|material|length|depth|ins_date|inp_date|joint_|lining_|pavement|project)/.test(normalized)) {
return "construction";
}
if (/来源|作者|更新|备注|图片|关键字|采集/.test(key)
|| /^(source|author|update_|cdate|bz|gjz|picture)/.test(normalized)) {
return "provenance";
}
if (/编号|编码|类别|类型|状态|功能|用途/.test(key)
|| /^(id|pipe_id|prjid|sblb|pipetype|status|purpose|isabandon|enabled)/.test(normalized)) {
return "identity";
}
return "other";
}
function getDatasetPresentation(layerKey: string) {
if (layerKey === "fx_20200423_pipes") {
return {
datasetRole: "基础管网",
description: "2020-04-23 全网除头桥基础快照,以拓扑、位置和基础业务字段为主。"
};
}
if (layerKey === "fx_20200811_dn100_pipes") {
return {
datasetRole: "扩展台账",
description: "2020-08-11 DN100 及以上专项台账,优先采用 PIPE_D,并补充材质、埋深和区域等字段。"
};
}
return {
datasetRole: "设施台账",
description: "源 GIS 设施记录,仅展示有业务含义的字段摘要。"
};
}
function toLocalizedEntry(key: string, value: unknown): LocalizedFeatureProperty {
return {
key,
label: getFeaturePropertyLabel(key),
value: formatFeaturePropertyValue(key, value)
};
}
function normalizeKey(key: string) {
return key.trim().toLowerCase();
}
@@ -12,6 +12,7 @@ export function createScheduledConditionAlerts(
id: `${SCHEDULED_CONDITION_ALERT_ID_PREFIX}${condition.id}`,
title: `待复核:${condition.title}`,
description: condition.detail ?? condition.summary,
severity: "critical",
time: formatScheduledConditionTime(condition.scheduledAt)
}));
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionRecord, WorkbenchAlert } from "../types";
import {
createAlertQueueConversationPrompt,
createConditionConversationPrompt
} from "./scheduled-condition-prompts";
const mockCondition: ScheduledConditionRecord = {
id: "mock-running",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "scheduled-condition-scada-diagnosis-test",
scheduledAt: "2026-09-17T10:00:00+08:00",
title: "SCADA 数据诊断",
summary: "演示摘要",
detail: "演示详情",
status: "running",
riskLevel: "attention",
updatedAt: Date.parse("2026-09-17T10:01:00+08:00")
};
const backendCondition: ScheduledConditionRecord = {
id: "backend-run",
kind: "condition",
taskId: "backend-schedule",
dataSource: "scheduled_tasks",
sessionId: "scheduled-condition-schedule-run-test",
scheduledAt: "2026-09-17T10:00:00+08:00",
title: "演示 后端调查",
summary: "后端摘要",
detail: "后端明确记录的详情",
evidence: ["后端证据 A"],
status: "running",
riskLevel: "attention",
updatedAt: Date.parse("2026-09-17T10:01:00+08:00"),
analysisInsight: {
summary: "不应发送的前端推断",
notes: [],
solutionOptions: [
{
id: "fake-option",
title: "虚构方案",
scenario: "虚构场景",
action: "虚构动作",
tradeoff: "虚构代价"
}
]
}
};
describe("scheduled condition prompts", () => {
it("keeps mock workflows and labels them as demonstration data", () => {
const prompt = createConditionConversationPrompt(mockCondition, "mock");
expect(prompt).toContain("来源:明确标注的演示数据");
expect(prompt).toContain("任务工作流:SCADA 诊断流程");
expect(prompt).toContain("以上内容为演示数据");
});
it("sends only persisted backend facts without inferred workflow steps", () => {
const prompt = createConditionConversationPrompt(backendCondition, "scheduled_tasks");
expect(prompt).toContain("来源:后端持久化运行态");
expect(prompt).toContain("后端明确记录的详情");
expect(prompt).toContain("后端证据 A");
expect(prompt).toContain("不要补造执行步骤、进度或阈值");
expect(prompt).not.toContain("任务工作流");
expect(prompt).not.toContain("SCADA 诊断流程");
});
it("does not include frontend solution inference in a backend alert summary", () => {
const alerts: WorkbenchAlert[] = [
{
id: "scheduled-condition-alert-backend-run",
conditionId: "backend-run",
title: "后端提醒",
description: "后端异常记录",
time: "10:01"
}
];
const prompt = createAlertQueueConversationPrompt({
alerts,
conditions: [backendCondition],
dataSource: "backend"
});
expect(prompt).toContain("数据来源:后端持久化运行态");
expect(prompt).toContain("后端证据 A");
expect(prompt).not.toContain("虚构方案");
expect(prompt).not.toContain("虚构动作");
});
});
@@ -1,5 +1,11 @@
import { getRunningWorkflowDefinition } from "../data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
import type {
ScheduledConditionItem,
ScheduledConditionItemStatus,
WorkbenchAlert,
WorkbenchAlertDataSource,
WorkbenchRuntimeDataSource
} from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
@@ -12,14 +18,21 @@ const statusLabels: Record<ScheduledConditionItemStatus, string> = {
pending: "预约"
};
export function createConditionConversationPrompt(condition: ScheduledConditionItem) {
export function createConditionConversationPrompt(
condition: ScheduledConditionItem,
dataSource: WorkbenchRuntimeDataSource = "mock"
) {
const effectiveDataSource = condition.kind === "condition"
? condition.dataSource ?? dataSource
: dataSource;
const backendCondition = effectiveDataSource !== "mock";
const workflow =
condition.kind === "condition"
condition.kind === "condition" && !backendCondition
? getRunningWorkflowDefinition(condition)
: null;
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
const kpis =
condition.kind === "condition" && condition.kpis?.length
!backendCondition && condition.kind === "condition" && condition.kpis?.length
? condition.kpis
.map((kpi, index) => {
const value = `${kpi.value}${kpi.unit ?? ""}`;
@@ -29,7 +42,7 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
.join("\n")
: "无";
const report =
condition.kind === "condition" && condition.report
!backendCondition && condition.kind === "condition" && condition.report
? [
condition.report.conclusion,
...condition.report.sections.map(
@@ -43,6 +56,7 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
"",
`时间:${formatTime(condition.scheduledAt)}`,
"类型:历史工况",
`来源:${backendCondition ? "后端持久化运行态" : "明确标注的演示数据"}`,
workflow ? `任务工作流:${workflow.title}` : null,
workflow ? `工作流步骤:${workflow.steps.map((step, index) => `${index + 1}. ${step.title}`).join(";")}` : null,
`标题:${condition.title}`,
@@ -56,6 +70,9 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
condition.sessionId ? `sessionId:${condition.sessionId}` : null,
"",
backendCondition
? "仅将上述状态、详情、证据和已有建议视为后端事实;不要补造执行步骤、进度或阈值,新增判断必须明确标注为分析建议。"
: "以上内容为演示数据,可用于测试对话流程,不代表真实运行记录。",
"请先说明当前判断,再列出需要人工确认的事项和建议动作。"
]
.filter(Boolean)
@@ -64,11 +81,14 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
export function createAlertQueueConversationPrompt({
alerts,
conditions
conditions,
dataSource = "mock"
}: {
alerts: WorkbenchAlert[];
conditions: ScheduledConditionItem[];
dataSource?: WorkbenchAlertDataSource;
}) {
const backendAlerts = dataSource !== "mock";
const alertSummaries = alerts.map((alert, index) => {
const condition = findConditionForAlert(alert, conditions);
const baseSummary = [
@@ -90,7 +110,10 @@ export function createAlertQueueConversationPrompt({
condition.detail ? `工况详情:${condition.detail}` : null,
condition.evidence?.length ? `关键证据:\n${condition.evidence.map((item, evidenceIndex) => `${evidenceIndex + 1}. ${item}`).join("\n")}` : null,
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
condition.kind === "condition" && condition.analysisInsight?.solutionOptions?.length
(condition.kind !== "condition"
|| (condition.dataSource ?? (backendAlerts ? "backend" : "mock")) === "mock")
&& condition.kind === "condition"
&& condition.analysisInsight?.solutionOptions?.length
? `已有处置方案:\n${condition.analysisInsight.solutionOptions.map((option, optionIndex) => `${optionIndex + 1}. ${option.title}:${option.action};代价:${option.tradeoff}`).join("\n")}`
: null
]
@@ -101,6 +124,11 @@ export function createAlertQueueConversationPrompt({
return [
"请作为供水管网调度 Agent,汇总当前待复核工况队列。",
"",
`数据来源:${backendAlerts ? "后端持久化运行态" : "明确标注的演示数据"}`,
backendAlerts
? "仅将后端记录的状态、详情、证据和已有建议视为事实;不要补造执行步骤、进度或阈值,新增判断必须明确标注为分析建议。"
: "这些内容为演示数据,用于测试汇总流程。",
"",
"目标:",
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
"2. 列出支撑判断的关键证据和不确定性。",
@@ -117,6 +145,9 @@ export function createAlertQueueConversationPrompt({
}
function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledConditionItem[]) {
if (alert.conditionId) {
return conditions.find((condition) => condition.id === alert.conditionId) ?? null;
}
if (!alert.id.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
return null;
}
@@ -258,10 +258,22 @@ const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FL
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date();
return factory(
const artifact = factory(
`analysis-${now.getTime().toString(36)}-${instance}`,
now.toLocaleTimeString("zh-CN", { hour12: false })
);
return {
...artifact,
origin: "demo",
title: `演示:${artifact.title}`,
subtitle: `前端固定测试数据 · ${artifact.subtitle}`,
sources: ["前端固定演示数据", ...artifact.sources],
limitations: [
"该成果仅用于测试界面和交互,不代表后端分析、现场状态或调度结论。",
...artifact.limitations
]
};
}
function resolveFixtureTimestamp(clock: string) {
@@ -126,14 +126,21 @@ export function AnalysisArtifactWorkspace({
}
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
const backendArtifact = artifact.origin === "backend";
const demoArtifact = artifact.origin === "demo";
const OriginIcon = backendArtifact ? Database : Sparkles;
return (
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
<div className="flex items-start justify-between gap-5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent Artifact
<OriginIcon size={14} aria-hidden="true" />
{backendArtifact
? "Backend Analysis"
: demoArtifact
? "前端演示成果"
: "Agent Artifact"}
</span>
<span aria-hidden="true">·</span>
<span>{artifactTypeLabel(artifact.type)}</span>
@@ -484,10 +491,14 @@ function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
return "无空间依赖";
}
const CHART_FONT_FAMILY =
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return {
animationDuration: 260,
animationEasing: "cubicOut",
textStyle: { fontFamily: CHART_FONT_FAMILY },
color: block.series.map((series) => series.color),
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
@@ -1,4 +1,4 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { ChevronUp, Database, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
@@ -17,9 +17,16 @@ export function ArtifactPeekBar({
onOpen: () => void;
onDestroy: () => void;
}) {
const backendArtifact = artifact.origin === "backend";
const demoArtifact = artifact.origin === "demo";
const OriginIcon = backendArtifact ? Database : Sparkles;
return (
<aside
aria-label="Agent 成果预览"
aria-label={backendArtifact
? "后端分析成果预览"
: demoArtifact
? "前端演示成果预览"
: "Agent 成果预览"}
className={cn(
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
@@ -31,8 +38,12 @@ export function ArtifactPeekBar({
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
<Sparkles size={12} aria-hidden="true" />
新分析成果 · R{artifact.revision}
<OriginIcon size={12} aria-hidden="true" />
{backendArtifact
? "后端分析记录"
: demoArtifact
? "前端演示成果"
: "新分析成果"} · R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
@@ -5,6 +5,7 @@ import {
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openAnalysisArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
@@ -28,10 +29,18 @@ export function useArtifactWorkspace(businessWorkspaceWidth: number) {
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const previewArtifact = useCallback((artifact: Parameters<typeof previewAnalysisArtifact>[1]) => {
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const openPreview = useCallback(() => {
setState(openPendingArtifact);
}, []);
const openArtifact = useCallback((artifact: Parameters<typeof openAnalysisArtifact>[1]) => {
setState((current) => openAnalysisArtifact(current, artifact));
}, []);
const collapseArtifact = useCallback(() => {
setState(collapseActiveArtifact);
}, []);
@@ -58,6 +67,8 @@ export function useArtifactWorkspace(businessWorkspaceWidth: number) {
surfaceMode,
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
previewArtifact,
openArtifact,
openPreview,
collapseArtifact,
destroyPreview,
@@ -51,17 +51,16 @@ export function WorkbenchMainFrame({
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const showArtifactViewControl = Boolean(activeArtifact);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
: mapSplitAvailable
? "半屏显示"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
@@ -110,20 +109,21 @@ export function WorkbenchMainFrame({
aria-hidden={!showArtifact}
className={cn(
"z-30 min-h-0 min-w-0 overflow-hidden",
surfaceMode === "focus" && "lg:pl-[84px]",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
animate={showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}}
exit={prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.992 }}
animate={
showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}
}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }}
transition={artifactTransition}
>
<AnalysisArtifactWorkspace
@@ -151,9 +151,11 @@ export function WorkbenchMainFrame({
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)}
initial={prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
initial={
prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }
}
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
@@ -161,11 +163,15 @@ export function WorkbenchMainFrame({
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
onClick={() =>
onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable
? "map_split"
: "map_only"
: "focus"
)
}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
@@ -174,7 +180,9 @@ export function WorkbenchMainFrame({
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
exit={
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }
}
transition={viewControlTransition}
>
{mapOnly ? (

Some files were not shown because too many files have changed in this diff Show More