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