feat: add operational timeline workspace

This commit is contained in:
2026-08-19 16:42:41 +08:00
parent d2f2895c36
commit 45f1ebee7e
19 changed files with 1127 additions and 105 deletions
@@ -13,6 +13,10 @@ const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
scope: "北辰供水分区 · 最近 6 小时",
confidence: "92.6%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
@@ -93,6 +97,10 @@ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
scope: "六个服务分区 · 今日峰值预测",
confidence: "88.4%",
generatedAt,
analyticalTimeRange: {
start: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(0, 0, 0, 0)).toISOString(),
end: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(23, 59, 59, 999)).toISOString()
},
preferredView: "focus",
mapRelation: "none",
blocks: [
@@ -171,6 +179,10 @@ const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
scope: "北辰低压事件 · 30 分钟模拟",
confidence: "90.8%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
@@ -251,3 +263,10 @@ export function createAnalysisArtifactFixture(index: number, instance: number):
now.toLocaleTimeString("zh-CN", { hour12: false })
);
}
function resolveFixtureTimestamp(clock: string) {
const [hours = 0, minutes = 0, seconds = 0] = clock.split(":").map(Number);
const value = new Date();
value.setHours(hours, minutes, seconds, 0);
return value.getTime();
}
@@ -70,7 +70,7 @@ export function AnalysisArtifactWorkspace({
return (
<article
aria-label={`${artifact.title}分析成果`}
className="relative grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-[#eef2f6] text-slate-900"
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
lang="zh-CN"
>
<ArtifactHeader artifact={artifact} />
@@ -138,7 +138,7 @@ export function AnalysisArtifactWorkspace({
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
return (
<header className="relative z-10 bg-white px-4 py-4 shadow-[0_1px_0_rgba(15,23,42,0.08),0_5px_18px_rgba(15,23,42,0.04)] xl:px-5">
<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">
@@ -190,7 +190,7 @@ function AnalysisBlockView({
<section
aria-labelledby={`${block.id}-title`}
className={cn(
"group min-w-0 overflow-hidden bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]",
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
spanClass,
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
@@ -281,11 +281,11 @@ function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "m
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return (
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
<div className="bg-[#e7edf3] px-4 py-3.5">
<div className="surface-well rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3>
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
</div>
<div className="bg-amber-50 px-4 py-3.5">
<div className="material-tone-warning rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul>
</div>
@@ -303,7 +303,7 @@ function ArtifactDetailDrawer({
onClose: () => void;
}) {
return (
<aside aria-label="证据详情" className="absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain bg-white shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<aside aria-label="证据详情" className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
@@ -355,7 +355,7 @@ function ArtifactActionBar({
onDestroy: () => void;
}) {
return (
<footer className="relative z-20 flex min-h-14 items-center justify-between gap-3 bg-white px-3 shadow-[0_-1px_0_rgba(15,23,42,0.08),0_-6px_18px_rgba(15,23,42,0.04)]">
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
<div className="flex min-w-0 items-center gap-1">
{artifact.mapRelation !== "none" ? (
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
@@ -392,7 +392,7 @@ function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactA
function MetricTile({ metric }: { metric: AnalysisMetric }) {
return (
<div className="min-w-0 bg-white px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
@@ -17,21 +17,21 @@ export function ArtifactPeekBar({
<aside
aria-label="Agent 成果预览"
className={cn(
"pointer-events-auto absolute bottom-4 z-30 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 bg-slate-950 px-3 py-2.5 text-slate-100 shadow-[0_18px_44px_rgba(15,23,42,0.28)]",
"bottom-16 left-1/2 -translate-x-1/2",
"acrylic-panel pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1rem)] z-30 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",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
>
<span className="hidden h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-300 sm:grid">
<span className="material-tone-agent hidden h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700 sm:grid">
<FileChartColumnIncreasing size={21} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-blue-300">
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
<Sparkles size={12} aria-hidden="true" />
· R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-50">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-400">{artifact.summary}</p>
<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>
</div>
<button
type="button"
@@ -46,7 +46,7 @@ export function ArtifactPeekBar({
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className="grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 hover:bg-white/10 hover:text-rose-300 active:scale-95"
className="surface-control grid h-10 w-10 shrink-0 place-items-center rounded-xl border text-slate-500 hover:text-rose-700 active:scale-95"
>
<Trash2 size={16} aria-hidden="true" />
</button>
@@ -13,7 +13,7 @@ import {
type ArtifactRequestedView
} from "./workspace-model";
export function useArtifactWorkspace(viewportWidth: number) {
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
const [state, setState] = useState(createInitialArtifactWorkspaceState);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
@@ -49,14 +49,14 @@ export function useArtifactWorkspace(viewportWidth: number) {
}, []);
const surfaceMode = useMemo(
() => resolveWorkbenchSurfaceMode(state, viewportWidth),
[state, viewportWidth]
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
[businessWorkspaceWidth, state]
);
return {
...state,
surfaceMode,
mapSplitAvailable: viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
openPreview,
collapseArtifact,
@@ -12,6 +12,8 @@ import type {
type WorkbenchMainFrameProps = {
mapContent: ReactNode;
mapOverlay?: ReactNode;
timelineContent: ReactNode;
timelineMode: "compact" | "expanded" | "summary";
activeArtifact: AnalysisArtifact | null;
pendingArtifact: AnalysisArtifact | null;
surfaceMode: WorkbenchSurfaceMode;
@@ -26,6 +28,8 @@ type WorkbenchMainFrameProps = {
export function WorkbenchMainFrame({
mapContent,
mapOverlay,
timelineContent,
timelineMode,
activeArtifact,
pendingArtifact,
surfaceMode,
@@ -41,8 +45,9 @@ export function WorkbenchMainFrame({
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 lg:left-[var(--workbench-agent-current-width)]"
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
data-testid="workbench-main-frame"
data-timeline-mode={timelineMode}
>
<div
id="main-workspace"
@@ -94,16 +99,27 @@ export function WorkbenchMainFrame({
/>
</section>
) : null}
{pendingArtifact ? (
<ArtifactPeekBar
artifact={pendingArtifact}
surfaceMode={surfaceMode}
onOpen={onOpenPendingArtifact}
onDestroy={onDestroyPendingArtifact}
/>
) : null}
</div>
{pendingArtifact ? (
<ArtifactPeekBar
artifact={pendingArtifact}
surfaceMode={surfaceMode}
onOpen={onOpenPendingArtifact}
onDestroy={onDestroyPendingArtifact}
/>
) : null}
<div
className={cn(
"pointer-events-auto absolute bottom-3 left-3 right-3 z-40 mx-auto min-h-0 w-[calc(100%-1.5rem)]",
timelineMode === "expanded" && "max-w-[1180px]",
timelineMode === "compact" && "max-w-[980px]",
timelineMode === "summary" && "max-w-[760px]"
)}
>
{timelineContent}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace-context";
describe("workspace context", () => {
it("keeps operational time independent from artifact analytical time", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const withArtifact = reduceWorkspaceContext(createInitialWorkspaceContext(), {
type: "sync-artifact",
artifact
});
const selectedEvent = reduceWorkspaceContext(withArtifact, {
type: "select-operational-event",
eventId: "event-1",
cursorTime: "2026-08-19T10:30:00.000Z"
});
expect(selectedEvent.analytical.timeRange).toEqual(artifact.analyticalTimeRange);
expect(selectedEvent.operational.cursorTime).toBe("2026-08-19T10:30:00.000Z");
});
it("updates analytical context without moving the operational cursor", () => {
const initial = createInitialWorkspaceContext(new Date("2026-08-19T08:00:00.000Z"));
const cursorTime = initial.operational.cursorTime;
const withArtifact = reduceWorkspaceContext(initial, {
type: "sync-artifact",
artifact: createAnalysisArtifactFixture(1, 1)
});
expect(withArtifact.operational.cursorTime).toBe(cursorTime);
expect(withArtifact.analytical.artifactId).toBeTruthy();
});
it("clears selection while retaining cursor until returning to now", () => {
const initial = createInitialWorkspaceContext();
const selected = reduceWorkspaceContext(initial, {
type: "select-operational-event",
eventId: "event-2",
cursorTime: "2026-08-19T13:10:00.000Z"
});
const cleared = reduceWorkspaceContext(selected, { type: "clear-operational-event" });
expect(cleared.operational.selectedEventId).toBeNull();
expect(cleared.operational.cursorTime).toBe("2026-08-19T13:10:00.000Z");
});
});
@@ -0,0 +1,102 @@
import type { AnalysisArtifact } from "./workspace-model";
export type WorkspaceTimeRange = { start: string; end: string };
export type WorkspaceContext = {
spatial: {
featureId: string | null;
sourceId: string | null;
};
operational: {
date: string;
cursorTime: string;
selectedEventId: string | null;
};
analytical: {
artifactId: string | null;
timeRange: WorkspaceTimeRange | null;
evidenceId: string | null;
};
};
export type WorkspaceContextAction =
| { type: "select-operational-event"; eventId: string; cursorTime: string }
| { type: "clear-operational-event" }
| { type: "return-operational-now"; now: string }
| { type: "select-spatial-feature"; featureId: string; sourceId: string }
| { type: "clear-spatial-feature" }
| { type: "sync-artifact"; artifact: AnalysisArtifact | null }
| { type: "select-evidence"; evidenceId: string | null };
export function createInitialWorkspaceContext(now = new Date()): WorkspaceContext {
return {
spatial: { featureId: null, sourceId: null },
operational: {
date: toLocalDateKey(now),
cursorTime: now.toISOString(),
selectedEventId: null
},
analytical: { artifactId: null, timeRange: null, evidenceId: null }
};
}
export function reduceWorkspaceContext(
state: WorkspaceContext,
action: WorkspaceContextAction
): WorkspaceContext {
switch (action.type) {
case "select-operational-event":
return {
...state,
operational: {
...state.operational,
cursorTime: action.cursorTime,
selectedEventId: action.eventId
}
};
case "clear-operational-event":
return {
...state,
operational: { ...state.operational, selectedEventId: null }
};
case "return-operational-now":
return {
...state,
operational: {
date: toLocalDateKey(new Date(action.now)),
cursorTime: action.now,
selectedEventId: null
}
};
case "select-spatial-feature":
return {
...state,
spatial: { featureId: action.featureId, sourceId: action.sourceId }
};
case "clear-spatial-feature":
return { ...state, spatial: { featureId: null, sourceId: null } };
case "sync-artifact":
return {
...state,
analytical: action.artifact
? {
artifactId: action.artifact.id,
timeRange: action.artifact.analyticalTimeRange,
evidenceId: null
}
: { artifactId: null, timeRange: null, evidenceId: null }
};
case "select-evidence":
return {
...state,
analytical: { ...state.analytical, evidenceId: action.evidenceId }
};
}
}
function toLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -1,4 +1,5 @@
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = 1360;
export const ARTIFACT_PANE_MIN_WIDTH = 640;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
@@ -88,6 +89,7 @@ export type AnalysisArtifact = {
scope: string;
confidence: string;
generatedAt: string;
analyticalTimeRange: { start: string; end: string };
preferredView: ArtifactViewMode;
mapRelation: ArtifactMapRelation;
blocks: AnalysisBlock[];
@@ -155,13 +157,13 @@ export function requestArtifactView(
export function resolveWorkbenchSurfaceMode(
state: ArtifactWorkspaceState,
viewportWidth: number
businessWorkspaceWidth: number
): WorkbenchSurfaceMode {
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";
return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
return businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
}
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {