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