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
@@ -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>