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
@@ -1,8 +1,6 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import { cn } from "@/shared/ui/cn";
import { useEffect, type ComponentProps } from "react";
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -10,8 +8,6 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
artifactActive: boolean;
conditionExpanded: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
@@ -24,8 +20,6 @@ export function WorkbenchAgentPanels({
panelProps,
panelOpen,
panelCollapsing,
artifactActive,
conditionExpanded,
personaState,
statusLabel,
viewportWidth,
@@ -33,9 +27,7 @@ export function WorkbenchAgentPanels({
onExpandPanel,
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const committedWidth = resize.committedWidth ?? defaultWidth;
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
useEffect(() => {
onWidthCommit?.(committedWidth);
@@ -43,43 +35,16 @@ export function WorkbenchAgentPanels({
return (
<div
ref={resize.panelRef}
className={cn(
"pointer-events-none absolute z-50 hidden lg:block",
!artifactActive && panelOpen && "workbench-agent-dock",
artifactActive && panelOpen
? "bottom-4 left-3 top-20 max-w-[720px]"
: "bottom-0 left-0 top-14"
)}
style={{ width: panelOpen ? resize.width ?? defaultWidth : 72 }}
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
style={{ width: panelOpen ? committedWidth : 72 }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
presentation={artifactActive ? "desktop-floating" : "desktop-dock"}
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={resize.handleKeyDown}
onPointerDown={resize.handlePointerDown}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
) : (
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
<AgentCollapsedRail
@@ -28,6 +28,7 @@ export type WorkbenchTopBarProps = {
alerts: WorkbenchAlert[];
user: WorkbenchUser;
conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
devPanelEnabled: boolean;
@@ -35,6 +36,7 @@ export type WorkbenchTopBarProps = {
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
onToggleDevPanel: () => void;
onPreviewScenario: () => void;
@@ -59,6 +61,7 @@ export function WorkbenchTopBar({
alerts,
user,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
taskTickerVisible,
devPanelEnabled,
@@ -66,6 +69,7 @@ export function WorkbenchTopBar({
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
onToggleDevPanel,
onPreviewScenario,
@@ -144,6 +148,12 @@ export function WorkbenchTopBar({
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
<div className="hidden lg:block">
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
{onCreateTestAnalysis ? (
<div className="hidden lg:block">
<button
@@ -257,6 +267,33 @@ function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle
);
}
function TimelineToggle({
visible,
compact = false,
onToggle
}: {
visible: boolean;
compact?: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
aria-pressed={visible}
data-selection-tone="soft"
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
onClick={onToggle}
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Clock3 size={14} aria-hidden="true" />
</span>
{!compact ? <span className={compactHeaderTextClassName}></span> : null}
</button>
);
}
function HeaderReadout({
icon: Icon,
label,
@@ -6,7 +6,7 @@ const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
type WorkbenchMobileSheet = "agent" | "condition" | null;
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
type UseWorkbenchResponsiveLayoutOptions = {
activeToolOpen: boolean;
@@ -146,6 +146,12 @@ export function useWorkbenchResponsiveLayout({
}
}
function openTimelineForViewport() {
if (isLargeScreen) return;
setMobileSheet("timeline");
setMobileSheetSnap("half");
}
const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
@@ -163,6 +169,7 @@ export function useWorkbenchResponsiveLayout({
mobileSheetSnap,
openAgentPanelForViewport,
openConditionFeedForViewport,
openTimelineForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
+8
View File
@@ -28,3 +28,11 @@ export type {
ArtifactViewMode,
WorkbenchSurfaceMode
} from "./workspace/workspace-model";
export type {
OperationalEvent,
OperationalEventImportance,
OperationalEventLane,
OperationalEventStatus,
OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
export type { WorkspaceContext, WorkspaceTimeRange } from "./workspace/workspace-context";
+208 -25
View File
@@ -2,20 +2,22 @@ import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ComponentProps,
type CSSProperties
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarClock } from "lucide-react";
import { CalendarClock, Clock3 } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult
type AgentUiResult,
type AgentWorkspaceContextItem
} from "@/features/agent";
import {
MapErrorNotice,
@@ -36,6 +38,13 @@ import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
createOperationalEvents,
getOperationalEventSummary,
type OperationalEvent,
type OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
import {
ToolbarPanel,
type ExportViewPreset,
@@ -47,6 +56,10 @@ import {
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import {
createInitialWorkspaceContext,
reduceWorkspaceContext
} from "./workspace/workspace-context";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
@@ -163,6 +176,7 @@ export function MapWorkbenchPage({
mobileSheetSnap,
openAgentPanelForViewport,
openConditionFeedForViewport,
openTimelineForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
@@ -178,18 +192,41 @@ export function MapWorkbenchPage({
expandAgentPanel,
onClearActiveTool: clearActiveTool
});
const artifactWorkspace = useArtifactWorkspace(viewportWidth);
const currentAgentWidth = leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const agentOverlaysWorkspace = artifactActive && leftPanelOpen;
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
const [timelineVisible, setTimelineVisible] = useState(true);
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
reduceWorkspaceContext,
undefined,
() => createInitialWorkspaceContext()
);
const operationalEvents = useMemo(
() => createOperationalEvents(scheduledConditions),
[scheduledConditions]
);
const operationalSummary = useMemo(
() => getOperationalEventSummary(operationalEvents),
[operationalEvents]
);
useEffect(() => {
if (artifactActive) {
collapseAgentPanel();
setDetailFeature(null);
return;
}
expandAgentPanel();
}, [artifactActive, collapseAgentPanel, expandAgentPanel]);
setTimelineMode((current) => {
if (artifactWorkspace.surfaceMode === "focus") return "summary";
return current === "summary" ? "compact" : current;
});
}, [artifactWorkspace.surfaceMode]);
useEffect(() => {
dispatchWorkspaceContext({
type: "sync-artifact",
artifact: artifactWorkspace.activeArtifact
});
}, [artifactWorkspace.activeArtifact]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
@@ -203,6 +240,13 @@ export function MapWorkbenchPage({
toggleConditionFeedForViewport();
}
function toggleTimelineVisibility() {
setTimelineVisible((current) => {
if (current && mobileSheet === "timeline") closeMobileSheet();
return !current;
});
}
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
impactVisible,
@@ -212,7 +256,7 @@ export function MapWorkbenchPage({
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen: agentOverlaysWorkspace,
leftPanelOpen: false,
rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth
@@ -308,6 +352,54 @@ export function MapWorkbenchPage({
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
const handleClearOperationalSelection = useCallback(() => {
dispatchWorkspaceContext({ type: "clear-operational-event" });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
const handleReturnOperationalNow = useCallback(() => {
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
const toolbarItems = useMemo<MapToolbarItem[]>(
() =>
waterNetworkToolbarItems.map((item) => ({
@@ -635,7 +727,7 @@ export function MapWorkbenchPage({
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding(
map,
agentOverlaysWorkspace,
false,
rightPanelOpen,
rightPanelExpanded,
agentPanelWidth
@@ -753,6 +845,35 @@ export function MapWorkbenchPage({
onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig
};
const selectedOperationalEvent = operationalEvents.find(
(event) => event.id === workspaceContext.operational.selectedEventId
) ?? null;
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
const items: AgentWorkspaceContextItem[] = [{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}];
if (workspaceContext.spatial.featureId) {
items.push({
id: "spatial-feature",
label: "地图要素",
value: workspaceContext.spatial.featureId,
tone: "info"
});
}
if (artifactWorkspace.activeArtifact) {
items.push({
id: "analytical-artifact",
label: "分析成果",
value: artifactWorkspace.activeArtifact.title,
tone: "info"
});
}
return items;
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
const agentPanelProps: Omit<
ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse"
@@ -771,6 +892,7 @@ export function MapWorkbenchPage({
selectedModel: agent.selectedModel,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -792,12 +914,17 @@ export function MapWorkbenchPage({
style={
{
...WORKBENCH_LAYOUT_CSS_VARIABLES,
"--workbench-agent-current-width": `${
artifactActive
? WORKBENCH_LAYOUT.collapsedAgentWidth
: leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth
"--workbench-agent-current-width": `${currentAgentWidth}px`,
"--workbench-timeline-height": `${
!timelineVisible
? 0
: !isLargeScreen
? 48
: timelineMode === "expanded"
? 240
: timelineMode === "summary"
? 52
: 72
}px`
} as CSSProperties
}
@@ -810,6 +937,7 @@ export function MapWorkbenchPage({
alerts={workbenchAlerts}
user={user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled}
@@ -817,6 +945,7 @@ export function MapWorkbenchPage({
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
@@ -841,6 +970,36 @@ export function MapWorkbenchPage({
onSetArtifactView={artifactWorkspace.setView}
onCollapseArtifact={artifactWorkspace.collapseArtifact}
onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode}
timelineContent={(
timelineVisible ? <>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<button
type="button"
onClick={openTimelineForViewport}
className="acrylic-control flex h-12 w-full items-center gap-2 rounded-2xl border px-3 text-left text-xs text-slate-700 lg:hidden"
>
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
<span className="font-semibold"></span>
<span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.exceptions}</span>
<span className="ml-auto text-slate-500"></span>
</button>
</> : null
)}
mapContent={(
<div
ref={mapContainerRef}
@@ -857,7 +1016,7 @@ export function MapWorkbenchPage({
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
<div className="absolute bottom-0 right-0 flex flex-col items-end gap-2">
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1rem)] right-0 flex flex-col items-end gap-2">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
@@ -871,8 +1030,6 @@ export function MapWorkbenchPage({
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
artifactActive={artifactActive}
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
viewportWidth={viewportWidth}
@@ -882,7 +1039,7 @@ export function MapWorkbenchPage({
/>
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute bottom-14 right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
@@ -942,7 +1099,7 @@ export function MapWorkbenchPage({
) : null}
</AnimatePresence>
<div className={`absolute z-30 lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "bottom-[4.25rem] right-3" : "bottom-3 left-3 right-3"}`}>
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}>
{mobileSheet === null ? (
<div className="flex items-center justify-center gap-2">
<button
@@ -976,7 +1133,13 @@ export function MapWorkbenchPage({
{mobileSheet ? (
<MobileWorkbenchSheet
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
: mobileSheet === "timeline"
? "运行时间轴抽屉"
: "工况任务抽屉"
}
snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet}
@@ -987,6 +1150,18 @@ export function MapWorkbenchPage({
presentation="mobile-sheet"
onCollapse={closeMobileSheet}
/>
) : mobileSheet === "timeline" ? (
<OperationalTimeline
mobile
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
) : (
<ScheduledConditionFeed
presentation="mobile-sheet"
@@ -1059,3 +1234,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
return "当前分辨率";
}
function formatContextClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionItem } from "../types";
import { clusterOperationalEvents, createOperationalEvents } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1,
durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度",
assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: []
};
describe("operational timeline model", () => {
it("creates linked plan and actual events for active work orders", () => {
const events = createOperationalEvents([workOrder]);
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed",
riskLevel: "normal", updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
});
it("aggregates nearby events into ten minute lane buckets", () => {
const events = createOperationalEvents([workOrder]);
const nearbyPlan = {
...events[0],
id: "plan-nearby",
cursorTime: "2026-08-19T10:05:00.000Z"
};
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
expect(clusters).toHaveLength(2);
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
});
});
@@ -0,0 +1,159 @@
import type { FeatureTarget } from "../map/workbench-map-controller";
import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEvent = {
id: string;
title: string;
description: string;
lane: OperationalEventLane;
status: OperationalEventStatus;
importance: OperationalEventImportance;
plannedTime: string | null;
actualTime: string | null;
cursorTime: string;
correlationId: string | null;
scheduleId: string;
conditionId: string | null;
sourceLabel: string;
mapTargets: FeatureTarget[];
};
export type OperationalEventCluster = {
id: string;
lane: OperationalEventLane;
bucketStart: number;
events: OperationalEvent[];
};
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
}
export function clusterOperationalEvents(
events: OperationalEvent[],
bucketMinutes = 10
): OperationalEventCluster[] {
const bucketMs = bucketMinutes * 60_000;
const clusters = new Map<string, OperationalEventCluster>();
events.forEach((event) => {
const timestamp = Date.parse(event.cursorTime);
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
const key = `${event.lane}-${bucketStart}`;
const cluster = clusters.get(key) ?? {
id: key,
lane: event.lane,
bucketStart,
events: []
};
cluster.events.push(event);
clusters.set(key, cluster);
});
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
}
export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
exceptions: events.filter((event) => event.lane === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") {
return [];
}
return [{
id: `exception-${item.id}`,
title: item.title,
description: item.report?.conclusion ?? item.summary,
lane: "exception",
status: item.status === "error" ? "failed" : "delayed",
importance: item.status === "error" ? "critical" : "important",
plannedTime: item.scheduledAt,
actualTime: item.scheduledAt,
cursorTime: item.scheduledAt,
correlationId: item.sessionId,
scheduleId: item.taskId,
conditionId: item.id,
sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item)
}];
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const planEvent: OperationalEvent = {
id: `plan-${item.id}`,
title: item.title,
description: `${item.location} · ${item.assignee}`,
lane: "plan",
status: "planned",
importance: item.priority === "urgent" ? "important" : "normal",
plannedTime: item.scheduledAt,
actualTime: null,
cursorTime: item.scheduledAt,
correlationId: item.code,
scheduleId: item.id,
conditionId: item.id,
sourceLabel: item.source,
mapTargets: getWorkOrderMapTargets(item)
};
if (item.status === "pending") {
return [planEvent];
}
const start = Date.parse(item.scheduledAt);
const actualTime = new Date(
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
).toISOString();
const actualEvent: OperationalEvent = {
...planEvent,
id: `actual-${item.id}`,
lane: "actual",
status: "executed",
actualTime,
cursorTime: actualTime,
description:
item.status === "running"
? `${item.assignee}正在执行,位置:${item.location}`
: `${item.assignee}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
}
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
if (item.taskId === "scada-diagnosis") {
return [{ sourceId: "scada", featureId: "SCADA-07" }];
}
if (item.taskId === "pump-energy") {
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
}
return [];
}
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
if (item.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
}
if (item.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
}
return [];
}
function extractAssetId(location: string) {
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
}
@@ -0,0 +1,402 @@
import {
CalendarClock,
Check,
ChevronDown,
ChevronUp,
Clock3,
OctagonAlert,
RotateCcw,
TriangleAlert
} from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import {
clusterOperationalEvents,
getOperationalEventSummary,
type OperationalEvent,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalTimelineMode
} from "./operational-timeline-model";
type OperationalTimelineProps = {
date: string;
cursorTime: string;
events: OperationalEvent[];
mode: OperationalTimelineMode;
selectedEventId: string | null;
mobile?: boolean;
onModeChange?: (mode: OperationalTimelineMode) => void;
onSelectEvent: (event: OperationalEvent) => void;
onClearSelection: () => void;
onReturnNow: () => void;
};
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
events,
mode,
selectedEventId,
mobile = false,
onModeChange,
onSelectEvent,
onClearSelection,
onReturnNow
}: OperationalTimelineProps) {
const summary = getOperationalEventSummary(events);
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : mode === "expanded" ? 240 : mode === "summary" ? 52 : 72;
if (mode === "summary" && !mobile) {
return (
<section
aria-label="运行时间轴摘要"
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
style={{ height }}
>
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
<span className="text-xs font-semibold">{formatDate(date)}</span>
<span className="h-4 w-px bg-slate-300" />
<TimelineSummary summary={summary} />
{selectedEvent ? (
<button
type="button"
onClick={onClearSelection}
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span>
)}
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</section>
);
}
return (
<TooltipProvider delayDuration={180}>
<section
aria-label="运行时间轴"
className={cn(
"flex min-h-0 flex-col overflow-hidden text-slate-900",
mobile
? "surface-reading h-full rounded-t-xl border-t"
: "acrylic-panel rounded-2xl border"
)}
style={{ height }}
>
<div
className={cn(
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
mode === "compact" && !mobile ? "h-8" : "h-12"
)}
>
<div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2">
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}></p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} />
{selectedEvent && !mobile ? (
<button
type="button"
onClick={onClearSelection}
title="清除当前事件选择"
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
{mode === "expanded" || mobile ? (
<div className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]">
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
<span />
{LANES.map((lane) => (
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
<TimeTicks />
{LANES.map((lane) => (
<TimelineLane
key={lane.id}
lane={lane.id}
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
style={{ left: `${cursorPosition}%` }}
>
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
</div>
</div>
</div>
) : (
<div className="relative min-h-0 flex-1 px-3">
<CompactTimeTicks />
<div className="absolute bottom-1 left-3 right-3 top-3.5">
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
compact
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
style={{ left: `${cursorPosition}%` }}
/>
</div>
</div>
)}
</section>
</TooltipProvider>
);
}
function TimelineLane({
lane,
clusters,
selectedEventId,
onSelectEvent
}: {
lane: OperationalEventLane;
clusters: OperationalEventCluster[];
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
return (
<div className="relative border-t border-slate-200" data-lane={lane}>
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
</div>
);
}
function TimelineMarker({
cluster,
compact = false,
markerIndex,
selectedEventId,
onSelectEvent
}: {
cluster: OperationalEventCluster;
compact?: boolean;
markerIndex: number;
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
const event = pickClusterEvent(cluster.events);
const selected = cluster.events.some((item) => item.id === selectedEventId);
const position = getDayPosition(event.cursorTime);
const exception = event.lane === "exception";
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
onClick={() => onSelectEvent(event)}
className={cn(
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
compact ? "h-2 w-2" : "h-5 w-5",
event.lane === "plan" &&
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception && event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
event.importance === "critical" && !compact && "h-6 w-6",
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
)}
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
>
{!compact ? <OperationalEventGlyph event={event} /> : null}
{cluster.events.length > 1 && !compact ? (
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
{cluster.events.length}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl">
<div className="flex items-center gap-2">
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
event.lane === "plan" && "bg-blue-50 text-blue-700",
event.lane === "actual" && "bg-emerald-50 text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800",
event.status === "failed" && "bg-rose-50 text-rose-700"
)}>
{operationalEventNatureLabel(event)}
</span>
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span>
</div>
<p className="mt-1.5 font-semibold">{formatClock(event.cursorTime)} · {event.title}</p>
<p className="mt-1 leading-5 text-slate-600">{event.description}</p>
{cluster.events.length > 1 ? <p className="mt-1 text-slate-500"> {cluster.events.length} </p> : null}
{event.conditionId ? <p className="mt-1 font-medium text-blue-700"></p> : null}
</TooltipContent>
</Tooltip>
);
}
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
if (event.status === "failed") {
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "exception") {
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "actual") {
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
}
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "关键异常";
if (event.lane === "exception") return "运行偏差";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span> <strong className="text-blue-700">{summary.planned}</strong></span>
<span> <strong className="text-emerald-700">{summary.active}</strong></span>
<span> <strong className="text-amber-700">{summary.exceptions}</strong></span>
</div>
);
}
function TimelineActions({
mode,
onModeChange,
onReturnNow
}: {
mode: OperationalTimelineMode;
onModeChange?: (mode: OperationalTimelineMode) => void;
onReturnNow: () => void;
}) {
return (
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
title="返回现在"
aria-label="返回现在"
onClick={onReturnNow}
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
>
<RotateCcw size={14} aria-hidden="true" />
</button>
{onModeChange ? (
<button
type="button"
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
>
{mode === "expanded" ? <ChevronDown size={15} /> : <ChevronUp size={15} />}
</button>
) : null}
</div>
);
}
function TimeTicks() {
return (
<div className="relative text-[9px] text-slate-600">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function CompactTimeTicks() {
return (
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0];
}
function importanceRank(value: OperationalEvent["importance"]) {
return value === "critical" ? 2 : value === "important" ? 1 : 0;
}
function getDayPosition(value: string) {
const date = new Date(value);
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
}
function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function formatDate(value: string) {
const [year, month, day] = value.split("-");
return `${year}.${month}.${day}`;
}
@@ -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 {