feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -258,10 +258,22 @@ const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FL
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date();
return factory(
const artifact = factory(
`analysis-${now.getTime().toString(36)}-${instance}`,
now.toLocaleTimeString("zh-CN", { hour12: false })
);
return {
...artifact,
origin: "demo",
title: `演示:${artifact.title}`,
subtitle: `前端固定测试数据 · ${artifact.subtitle}`,
sources: ["前端固定演示数据", ...artifact.sources],
limitations: [
"该成果仅用于测试界面和交互,不代表后端分析、现场状态或调度结论。",
...artifact.limitations
]
};
}
function resolveFixtureTimestamp(clock: string) {
@@ -126,14 +126,21 @@ export function AnalysisArtifactWorkspace({
}
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
const backendArtifact = artifact.origin === "backend";
const demoArtifact = artifact.origin === "demo";
const OriginIcon = backendArtifact ? Database : Sparkles;
return (
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
<div className="flex items-start justify-between gap-5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent Artifact
<OriginIcon size={14} aria-hidden="true" />
{backendArtifact
? "Backend Analysis"
: demoArtifact
? "前端演示成果"
: "Agent Artifact"}
</span>
<span aria-hidden="true">·</span>
<span>{artifactTypeLabel(artifact.type)}</span>
@@ -484,10 +491,14 @@ function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
return "无空间依赖";
}
const CHART_FONT_FAMILY =
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return {
animationDuration: 260,
animationEasing: "cubicOut",
textStyle: { fontFamily: CHART_FONT_FAMILY },
color: block.series.map((series) => series.color),
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
@@ -1,4 +1,4 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { ChevronUp, Database, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
@@ -17,9 +17,16 @@ export function ArtifactPeekBar({
onOpen: () => void;
onDestroy: () => void;
}) {
const backendArtifact = artifact.origin === "backend";
const demoArtifact = artifact.origin === "demo";
const OriginIcon = backendArtifact ? Database : Sparkles;
return (
<aside
aria-label="Agent 成果预览"
aria-label={backendArtifact
? "后端分析成果预览"
: demoArtifact
? "前端演示成果预览"
: "Agent 成果预览"}
className={cn(
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
@@ -31,8 +38,12 @@ export function ArtifactPeekBar({
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
<Sparkles size={12} aria-hidden="true" />
新分析成果 · R{artifact.revision}
<OriginIcon size={12} aria-hidden="true" />
{backendArtifact
? "后端分析记录"
: demoArtifact
? "前端演示成果"
: "新分析成果"} · R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
@@ -5,6 +5,7 @@ import {
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openAnalysisArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
@@ -28,10 +29,18 @@ export function useArtifactWorkspace(businessWorkspaceWidth: number) {
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const previewArtifact = useCallback((artifact: Parameters<typeof previewAnalysisArtifact>[1]) => {
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const openPreview = useCallback(() => {
setState(openPendingArtifact);
}, []);
const openArtifact = useCallback((artifact: Parameters<typeof openAnalysisArtifact>[1]) => {
setState((current) => openAnalysisArtifact(current, artifact));
}, []);
const collapseArtifact = useCallback(() => {
setState(collapseActiveArtifact);
}, []);
@@ -58,6 +67,8 @@ export function useArtifactWorkspace(businessWorkspaceWidth: number) {
surfaceMode,
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
previewArtifact,
openArtifact,
openPreview,
collapseArtifact,
destroyPreview,
@@ -51,17 +51,16 @@ export function WorkbenchMainFrame({
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const showArtifactViewControl = Boolean(activeArtifact);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
: mapSplitAvailable
? "半屏显示"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
@@ -110,20 +109,21 @@ export function WorkbenchMainFrame({
aria-hidden={!showArtifact}
className={cn(
"z-30 min-h-0 min-w-0 overflow-hidden",
surfaceMode === "focus" && "lg:pl-[84px]",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
animate={showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}}
exit={prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.992 }}
animate={
showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}
}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }}
transition={artifactTransition}
>
<AnalysisArtifactWorkspace
@@ -151,9 +151,11 @@ export function WorkbenchMainFrame({
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)}
initial={prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
initial={
prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }
}
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
@@ -161,11 +163,15 @@ export function WorkbenchMainFrame({
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
onClick={() =>
onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable
? "map_split"
: "map_only"
: "focus"
)
}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
@@ -174,7 +180,9 @@ export function WorkbenchMainFrame({
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
exit={
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }
}
transition={viewControlTransition}
>
{mapOnly ? (
@@ -6,6 +6,7 @@ import {
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openAnalysisArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
@@ -13,6 +14,15 @@ import {
} from "./workspace-model";
describe("artifact workspace state", () => {
it("marks generated fixture artifacts as frontend demonstrations", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
expect(artifact.origin).toBe("demo");
expect(artifact.title).toMatch(/^演示:/);
expect(artifact.sources).toContain("前端固定演示数据");
expect(artifact.limitations[0]).toContain("不代表后端分析");
});
it("previews generated artifacts without taking over the workspace", () => {
const initial = createInitialArtifactWorkspaceState();
const artifact = createAnalysisArtifactFixture(0, 0);
@@ -35,7 +45,16 @@ describe("artifact workspace state", () => {
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
});
it("keeps non-spatial artifacts focused at every desktop width", () => {
it("opens an available analysis artifact directly without creating a preview", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const opened = openAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
expect(opened.pendingArtifact).toBeNull();
expect(opened.activeArtifact).toBe(artifact);
expect(opened.requestedView).toBe(artifact.preferredView);
});
it("keeps a non-spatial artifact focused by default but allows an explicit split", () => {
const artifact = createAnalysisArtifactFixture(1, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
@@ -43,7 +62,11 @@ describe("artifact workspace state", () => {
expect(artifact.mapRelation).toBe("none");
expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("map_split");
expect(resolveWorkbenchSurfaceMode(
requestArtifactView(opened, "map_split"),
ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1
)).toBe("focus");
});
it("supports explicit focus and map restoration for spatial artifacts", () => {
@@ -1,4 +1,6 @@
export const ARTIFACT_PANE_MIN_WIDTH = 640;
import type { FeatureCollection, Geometry } from "geojson";
export const ARTIFACT_PANE_MIN_WIDTH = 600;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
@@ -80,6 +82,7 @@ export type AnalysisArtifactAction = {
export type AnalysisArtifact = {
id: string;
origin?: "agent" | "backend" | "demo";
type: ArtifactType;
lifecycle: ArtifactLifecycle;
revision: number;
@@ -92,6 +95,7 @@ export type AnalysisArtifact = {
analyticalTimeRange: { start: string; end: string };
preferredView: ArtifactViewMode;
mapRelation: ArtifactMapRelation;
spatialOverlay?: FeatureCollection<Geometry>;
blocks: AnalysisBlock[];
sources: string[];
limitations: string[];
@@ -128,6 +132,17 @@ export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWork
};
}
export function openAnalysisArtifact(
state: ArtifactWorkspaceState,
artifact: AnalysisArtifact
): ArtifactWorkspaceState {
return {
pendingArtifact: null,
activeArtifact: artifact,
requestedView: getPreferredArtifactView(artifact)
};
}
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
if (!state.activeArtifact) return state;
return {
@@ -162,7 +177,7 @@ export function resolveWorkbenchSurfaceMode(
const artifact = state.activeArtifact;
if (!artifact) return "default";
if (state.requestedView === "map_only") return "map_only";
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
if (state.requestedView === "focus") return "focus";
return businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
}