底图
-
+
{options.map((option) => {
diff --git a/src/features/map/core/components/legend.tsx b/src/features/map/core/components/legend.tsx
index e68cd54..af4aebf 100644
--- a/src/features/map/core/components/legend.tsx
+++ b/src/features/map/core/components/legend.tsx
@@ -1,11 +1,8 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/ui/cn";
-import { StatusDot } from "@/shared/ui/status";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
- MAP_READABLE_RADIUS_CLASS_NAME,
- MAP_READABLE_SURFACE_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
@@ -14,9 +11,15 @@ export type MapLegendItem = {
label: string;
description?: string;
color: string;
+ layerControlId?: string;
+ group?: string;
+ groupLabel?: string;
+ groupDescription?: string;
+ groupLayout?: "list" | "grid";
icon?: LucideIcon;
imageSrc?: string;
- shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
+ lineWidth?: number;
+ shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square" | "area";
};
type MapLegendProps = {
@@ -37,7 +40,7 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
)}
>
{title}
-
业务图层语义
+
当前地图符号与分级
);
@@ -46,44 +49,134 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
type MapLegendListProps = {
items: MapLegendItem[];
className?: string;
- showStatusDot?: boolean;
};
-export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
- return (
-
- {items.map((item) => {
- const Icon = item.icon;
+export function MapLegendList({ items, className = "" }: MapLegendListProps) {
+ const groups = groupLegendItems(items);
- return (
- -
-
- {item.imageSrc ?
: Icon ? : }
-
+ if (groups.length === 0) {
+ return (
+
+
暂无可见业务图层
+
请先在图层面板中打开需要查看的内容。
+
+ );
+ }
+
+ return (
+
+ {groups.map((group) => (
+
+
- {item.label}
- {item.description ? {item.description} : null}
+ {group.label}
+ {group.description ? (
+
+ {group.description}
+
+ ) : null}
- {showStatusDot ? : null}
-
- );
- })}
-
+
+ {group.items.length} 类
+
+
+
+ {group.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
);
}
-export function MapLegendSwatch({ color, shape = "dot" }: Pick) {
- if (shape === "line") {
- return ;
- }
+function groupLegendItems(items: MapLegendItem[]) {
+ const groups = new Map();
+ items.forEach((item) => {
+ const id = item.group ?? "legend";
+ const existing = groups.get(id);
+ if (existing) {
+ existing.items.push(item);
+ return;
+ }
+ groups.set(id, {
+ id,
+ label: item.groupLabel ?? "地图符号",
+ description: item.groupDescription,
+ layout: item.groupLayout ?? "list",
+ items: [item]
+ });
+ });
+ return [...groups.values()];
+}
- if (shape === "dashed-line" || shape === "dash-dot-line") {
+function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }) {
+ const Icon = item.icon;
+ return (
+ -
+
+ {item.imageSrc ? (
+
+ ) : Icon ? (
+
+ ) : (
+
+ )}
+
+
+ {item.label}
+ {compact && item.description ? (
+ {item.description}
+ ) : null}
+
+ {!compact && item.description ? (
+ {item.description}
+ ) : null}
+
+ );
+}
+
+export function MapLegendSwatch({
+ color,
+ shape = "dot",
+ lineWidth = 3
+}: Pick) {
+ if (shape === "line" || shape === "dashed-line" || shape === "dash-dot-line") {
+ const dashArray = shape === "dashed-line" ? "5 3" : shape === "dash-dot-line" ? "8 3 2 3" : undefined;
return (
-
);
}
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 }}
/>
diff --git a/src/features/workbench/components/analysis-history-section.tsx b/src/features/workbench/components/analysis-history-section.tsx
new file mode 100644
index 0000000..3ba36b6
--- /dev/null
+++ b/src/features/workbench/components/analysis-history-section.tsx
@@ -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
([]);
+ const [nextOffset, setNextOffset] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [openingRunId, setOpeningRunId] = useState(null);
+ const [error, setError] = useState(null);
+ const loadedRef = useRef(false);
+ const listControllerRef = useRef(null);
+ const artifactControllerRef = useRef(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 (
+ }
+ tone="info"
+ meta={loadedRef.current ? (
+
+ 已加载 {items.length} 项
+
+ ) : null}
+ >
+ {
+ if (event.currentTarget.open && !loadedRef.current && !loading) void load();
+ }}
+ >
+
+ {error ?
{error} : null}
+ {loading && !items.length ? (
+
+ 正在读取分析成果历史
+
+ ) : null}
+ {!loading && loadedRef.current && !items.length ? (
+
}
+ title="暂无持久化分析成果"
+ description="新的分析运行会在保存到当前项目 BizDB 后出现在这里。"
+ />
+ ) : null}
+ {items.length ? (
+
+ {items.map((item) => (
+ -
+
+
+ ))}
+
+ ) : null}
+
+ {error && !items.length ? (
+
+ ) : null}
+ {nextOffset !== null ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+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 : "分析成果历史请求失败";
+}
diff --git a/src/features/workbench/components/analysis-results-panel.tsx b/src/features/workbench/components/analysis-results-panel.tsx
new file mode 100644
index 0000000..10de15a
--- /dev/null
+++ b/src/features/workbench/components/analysis-results-panel.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/features/workbench/components/feature-popover.test.tsx b/src/features/workbench/components/feature-popover.test.tsx
new file mode 100644
index 0000000..561212f
--- /dev/null
+++ b/src/features/workbench/components/feature-popover.test.tsx
@@ -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(),
+ 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(
+ "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(
+ "read-token"}
+ onClose={vi.fn()}
+ />
+ );
+
+ await waitFor(() => expect(mocks.fetchElementGisProperties).toHaveBeenCalledOnce());
+ expect(screen.queryByText("GIS 台账属性")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/workbench/components/feature-popover.tsx b/src/features/workbench/components/feature-popover.tsx
index 2440531..9fe4dc3 100644
--- a/src/features/workbench/components/feature-popover.tsx
+++ b/src/features/workbench/components/feature-popover.tsx
@@ -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;
@@ -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;
-export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
+export function FeaturePopover({
+ feature,
+ projectId,
+ getAccessToken,
+ onClose
+}: FeaturePopoverProps) {
const [copiedId, setCopiedId] = useState(null);
+ const [expandedGisFeatureKey, setExpandedGisFeatureKey] = useState(null);
const copyResetTimeoutRef = useRef | 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 (