feat: add operational timeline workspace
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user