feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user