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
+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));
}