feat: add operational timeline workspace
This commit is contained in:
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
|
|||||||
<aside
|
<aside
|
||||||
aria-label="Agent 折叠栏"
|
aria-label="Agent 折叠栏"
|
||||||
className={cn(
|
className={cn(
|
||||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
@@ -82,6 +83,13 @@ import type {
|
|||||||
|
|
||||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||||
|
|
||||||
|
export type AgentWorkspaceContextItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "info" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
type AgentCommandPanelProps = {
|
type AgentCommandPanelProps = {
|
||||||
presentation?: AgentCommandPanelPresentation;
|
presentation?: AgentCommandPanelPresentation;
|
||||||
collapsing?: boolean;
|
collapsing?: boolean;
|
||||||
@@ -99,6 +107,7 @@ type AgentCommandPanelProps = {
|
|||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
approvalMode?: AgentApprovalMode;
|
approvalMode?: AgentApprovalMode;
|
||||||
uiResults?: AgentUiResult[];
|
uiResults?: AgentUiResult[];
|
||||||
|
workspaceContext?: AgentWorkspaceContextItem[];
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onRefreshHistory?: () => Promise<void> | void;
|
onRefreshHistory?: () => Promise<void> | void;
|
||||||
onStartNewSession?: () => Promise<void> | void;
|
onStartNewSession?: () => Promise<void> | void;
|
||||||
@@ -150,6 +159,7 @@ export function AgentCommandPanel({
|
|||||||
selectedModel = "",
|
selectedModel = "",
|
||||||
approvalMode = "request",
|
approvalMode = "request",
|
||||||
uiResults = [],
|
uiResults = [],
|
||||||
|
workspaceContext = [],
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onRefreshHistory,
|
onRefreshHistory,
|
||||||
onStartNewSession,
|
onStartNewSession,
|
||||||
@@ -302,7 +312,7 @@ export function AgentCommandPanel({
|
|||||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||||
"agent-panel-shell",
|
"agent-panel-shell",
|
||||||
presentation === "desktop-dock"
|
presentation === "desktop-dock"
|
||||||
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
|
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
|
||||||
: desktopFloating
|
: desktopFloating
|
||||||
? "acrylic-panel overflow-visible rounded-2xl border"
|
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||||
@@ -423,6 +433,27 @@ export function AgentCommandPanel({
|
|||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{workspaceContext.length > 0 ? (
|
||||||
|
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
|
||||||
|
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
|
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
|
||||||
|
{workspaceContext.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-md px-2 py-1",
|
||||||
|
item.tone === "info" && "bg-blue-50 text-blue-700",
|
||||||
|
item.tone === "warning" && "bg-amber-50 text-amber-700",
|
||||||
|
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
|
||||||
|
)}
|
||||||
|
title={`${item.label}:${item.value}`}
|
||||||
|
>
|
||||||
|
{item.label} · <strong className="font-semibold">{item.value}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{floatingPresentation ? (
|
{floatingPresentation ? (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{hasConversation && !historyOpen ? (
|
{hasConversation && !historyOpen ? (
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||||
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
|
export type {
|
||||||
|
AgentCommandPanelPresentation,
|
||||||
|
AgentWorkspaceContextItem
|
||||||
|
} from "./components/agent-command-panel";
|
||||||
export { AgentPersona } from "./components/agent-persona";
|
export { AgentPersona } from "./components/agent-persona";
|
||||||
export { createAgentApiClient } from "./api/client";
|
export { createAgentApiClient } from "./api/client";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||||
import { cn } from "@/shared/ui/cn";
|
|
||||||
import { useEffect, type ComponentProps } from "react";
|
import { useEffect, type ComponentProps } from "react";
|
||||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
|
||||||
|
|
||||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||||
|
|
||||||
@@ -10,8 +8,6 @@ type WorkbenchAgentPanelsProps = {
|
|||||||
panelProps: AgentPanelProps;
|
panelProps: AgentPanelProps;
|
||||||
panelOpen: boolean;
|
panelOpen: boolean;
|
||||||
panelCollapsing: boolean;
|
panelCollapsing: boolean;
|
||||||
artifactActive: boolean;
|
|
||||||
conditionExpanded: boolean;
|
|
||||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
@@ -24,8 +20,6 @@ export function WorkbenchAgentPanels({
|
|||||||
panelProps,
|
panelProps,
|
||||||
panelOpen,
|
panelOpen,
|
||||||
panelCollapsing,
|
panelCollapsing,
|
||||||
artifactActive,
|
|
||||||
conditionExpanded,
|
|
||||||
personaState,
|
personaState,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
@@ -33,9 +27,7 @@ export function WorkbenchAgentPanels({
|
|||||||
onExpandPanel,
|
onExpandPanel,
|
||||||
onWidthCommit
|
onWidthCommit
|
||||||
}: WorkbenchAgentPanelsProps) {
|
}: WorkbenchAgentPanelsProps) {
|
||||||
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
|
||||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWidthCommit?.(committedWidth);
|
onWidthCommit?.(committedWidth);
|
||||||
@@ -43,43 +35,16 @@ export function WorkbenchAgentPanels({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={resize.panelRef}
|
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
||||||
className={cn(
|
style={{ width: panelOpen ? committedWidth : 72 }}
|
||||||
"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 }}
|
|
||||||
>
|
>
|
||||||
{panelOpen ? (
|
{panelOpen ? (
|
||||||
<>
|
<AgentCommandPanel
|
||||||
<AgentCommandPanel
|
{...panelProps}
|
||||||
{...panelProps}
|
presentation="desktop-dock"
|
||||||
presentation={artifactActive ? "desktop-floating" : "desktop-dock"}
|
collapsing={panelCollapsing}
|
||||||
collapsing={panelCollapsing}
|
onCollapse={onCollapsePanel}
|
||||||
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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
|
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<AgentCollapsedRail
|
<AgentCollapsedRail
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type WorkbenchTopBarProps = {
|
|||||||
alerts: WorkbenchAlert[];
|
alerts: WorkbenchAlert[];
|
||||||
user: WorkbenchUser;
|
user: WorkbenchUser;
|
||||||
conditionFeedVisible: boolean;
|
conditionFeedVisible: boolean;
|
||||||
|
timelineVisible: boolean;
|
||||||
taskTickerAvailable: boolean;
|
taskTickerAvailable: boolean;
|
||||||
taskTickerVisible: boolean;
|
taskTickerVisible: boolean;
|
||||||
devPanelEnabled: boolean;
|
devPanelEnabled: boolean;
|
||||||
@@ -35,6 +36,7 @@ export type WorkbenchTopBarProps = {
|
|||||||
onSelectScenario: (scenarioId: string) => void;
|
onSelectScenario: (scenarioId: string) => void;
|
||||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||||
onToggleConditionFeed: () => void;
|
onToggleConditionFeed: () => void;
|
||||||
|
onToggleTimeline: () => void;
|
||||||
onToggleTaskTicker: () => void;
|
onToggleTaskTicker: () => void;
|
||||||
onToggleDevPanel: () => void;
|
onToggleDevPanel: () => void;
|
||||||
onPreviewScenario: () => void;
|
onPreviewScenario: () => void;
|
||||||
@@ -59,6 +61,7 @@ export function WorkbenchTopBar({
|
|||||||
alerts,
|
alerts,
|
||||||
user,
|
user,
|
||||||
conditionFeedVisible,
|
conditionFeedVisible,
|
||||||
|
timelineVisible,
|
||||||
taskTickerAvailable,
|
taskTickerAvailable,
|
||||||
taskTickerVisible,
|
taskTickerVisible,
|
||||||
devPanelEnabled,
|
devPanelEnabled,
|
||||||
@@ -66,6 +69,7 @@ export function WorkbenchTopBar({
|
|||||||
onSelectScenario,
|
onSelectScenario,
|
||||||
onSelectAlert,
|
onSelectAlert,
|
||||||
onToggleConditionFeed,
|
onToggleConditionFeed,
|
||||||
|
onToggleTimeline,
|
||||||
onToggleTaskTicker,
|
onToggleTaskTicker,
|
||||||
onToggleDevPanel,
|
onToggleDevPanel,
|
||||||
onPreviewScenario,
|
onPreviewScenario,
|
||||||
@@ -144,6 +148,12 @@ export function WorkbenchTopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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 ? (
|
{onCreateTestAnalysis ? (
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<button
|
<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({
|
function HeaderReadout({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const CONDITION_FEED_EXIT_MS = 170;
|
|||||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||||
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
||||||
|
|
||||||
type WorkbenchMobileSheet = "agent" | "condition" | null;
|
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
|
||||||
|
|
||||||
type UseWorkbenchResponsiveLayoutOptions = {
|
type UseWorkbenchResponsiveLayoutOptions = {
|
||||||
activeToolOpen: boolean;
|
activeToolOpen: boolean;
|
||||||
@@ -146,6 +146,12 @@ export function useWorkbenchResponsiveLayout({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTimelineForViewport() {
|
||||||
|
if (isLargeScreen) return;
|
||||||
|
setMobileSheet("timeline");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
|
|
||||||
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
||||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||||
const rightPanelExpanded =
|
const rightPanelExpanded =
|
||||||
@@ -163,6 +169,7 @@ export function useWorkbenchResponsiveLayout({
|
|||||||
mobileSheetSnap,
|
mobileSheetSnap,
|
||||||
openAgentPanelForViewport,
|
openAgentPanelForViewport,
|
||||||
openConditionFeedForViewport,
|
openConditionFeedForViewport,
|
||||||
|
openTimelineForViewport,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
setAgentPanelWidth,
|
setAgentPanelWidth,
|
||||||
|
|||||||
@@ -28,3 +28,11 @@ export type {
|
|||||||
ArtifactViewMode,
|
ArtifactViewMode,
|
||||||
WorkbenchSurfaceMode
|
WorkbenchSurfaceMode
|
||||||
} from "./workspace/workspace-model";
|
} 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";
|
||||||
|
|||||||
@@ -2,20 +2,22 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CSSProperties
|
type CSSProperties
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { CalendarClock } from "lucide-react";
|
import { CalendarClock, Clock3 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AgentCommandPanel,
|
AgentCommandPanel,
|
||||||
AgentPersona,
|
AgentPersona,
|
||||||
toTrustedMapAction,
|
toTrustedMapAction,
|
||||||
type FrontendActionRequest,
|
type FrontendActionRequest,
|
||||||
type UIEnvelopePayload,
|
type UIEnvelopePayload,
|
||||||
type AgentUiResult
|
type AgentUiResult,
|
||||||
|
type AgentWorkspaceContextItem
|
||||||
} from "@/features/agent";
|
} from "@/features/agent";
|
||||||
import {
|
import {
|
||||||
MapErrorNotice,
|
MapErrorNotice,
|
||||||
@@ -36,6 +38,13 @@ import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
|||||||
import { FeaturePopover } from "./components/feature-popover";
|
import { FeaturePopover } from "./components/feature-popover";
|
||||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
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 {
|
import {
|
||||||
ToolbarPanel,
|
ToolbarPanel,
|
||||||
type ExportViewPreset,
|
type ExportViewPreset,
|
||||||
@@ -47,6 +56,10 @@ import {
|
|||||||
WorkbenchMainFrame
|
WorkbenchMainFrame
|
||||||
} from "./workspace/workbench-main-frame";
|
} from "./workspace/workbench-main-frame";
|
||||||
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||||
|
import {
|
||||||
|
createInitialWorkspaceContext,
|
||||||
|
reduceWorkspaceContext
|
||||||
|
} from "./workspace/workspace-context";
|
||||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||||
@@ -163,6 +176,7 @@ export function MapWorkbenchPage({
|
|||||||
mobileSheetSnap,
|
mobileSheetSnap,
|
||||||
openAgentPanelForViewport,
|
openAgentPanelForViewport,
|
||||||
openConditionFeedForViewport,
|
openConditionFeedForViewport,
|
||||||
|
openTimelineForViewport,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
setAgentPanelWidth,
|
setAgentPanelWidth,
|
||||||
@@ -178,18 +192,41 @@ export function MapWorkbenchPage({
|
|||||||
expandAgentPanel,
|
expandAgentPanel,
|
||||||
onClearActiveTool: clearActiveTool
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (artifactActive) {
|
setTimelineMode((current) => {
|
||||||
collapseAgentPanel();
|
if (artifactWorkspace.surfaceMode === "focus") return "summary";
|
||||||
setDetailFeature(null);
|
return current === "summary" ? "compact" : current;
|
||||||
return;
|
});
|
||||||
}
|
}, [artifactWorkspace.surfaceMode]);
|
||||||
expandAgentPanel();
|
|
||||||
}, [artifactActive, collapseAgentPanel, expandAgentPanel]);
|
useEffect(() => {
|
||||||
|
dispatchWorkspaceContext({
|
||||||
|
type: "sync-artifact",
|
||||||
|
artifact: artifactWorkspace.activeArtifact
|
||||||
|
});
|
||||||
|
}, [artifactWorkspace.activeArtifact]);
|
||||||
|
|
||||||
function openAgentWorkspaceForViewport() {
|
function openAgentWorkspaceForViewport() {
|
||||||
openAgentPanelForViewport();
|
openAgentPanelForViewport();
|
||||||
@@ -203,6 +240,13 @@ export function MapWorkbenchPage({
|
|||||||
toggleConditionFeedForViewport();
|
toggleConditionFeedForViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTimelineVisibility() {
|
||||||
|
setTimelineVisible((current) => {
|
||||||
|
if (current && mobileSheet === "timeline") closeMobileSheet();
|
||||||
|
return !current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
@@ -212,7 +256,7 @@ export function MapWorkbenchPage({
|
|||||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen: agentOverlaysWorkspace,
|
leftPanelOpen: false,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
@@ -308,6 +352,54 @@ export function MapWorkbenchPage({
|
|||||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
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[]>(
|
const toolbarItems = useMemo<MapToolbarItem[]>(
|
||||||
() =>
|
() =>
|
||||||
waterNetworkToolbarItems.map((item) => ({
|
waterNetworkToolbarItems.map((item) => ({
|
||||||
@@ -635,7 +727,7 @@ export function MapWorkbenchPage({
|
|||||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||||
padding: getResponsiveWorkbenchPadding(
|
padding: getResponsiveWorkbenchPadding(
|
||||||
map,
|
map,
|
||||||
agentOverlaysWorkspace,
|
false,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
@@ -753,6 +845,35 @@ export function MapWorkbenchPage({
|
|||||||
onShowShortcuts: handleShowShortcuts,
|
onShowShortcuts: handleShowShortcuts,
|
||||||
onExportConfig: handleExportConfig
|
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<
|
const agentPanelProps: Omit<
|
||||||
ComponentProps<typeof AgentCommandPanel>,
|
ComponentProps<typeof AgentCommandPanel>,
|
||||||
"collapsing" | "onCollapse"
|
"collapsing" | "onCollapse"
|
||||||
@@ -771,6 +892,7 @@ export function MapWorkbenchPage({
|
|||||||
selectedModel: agent.selectedModel,
|
selectedModel: agent.selectedModel,
|
||||||
approvalMode: agent.approvalMode,
|
approvalMode: agent.approvalMode,
|
||||||
uiResults: activeAgentUiResults,
|
uiResults: activeAgentUiResults,
|
||||||
|
workspaceContext: agentWorkspaceContext,
|
||||||
onRefreshHistory: agent.refreshSessionHistory,
|
onRefreshHistory: agent.refreshSessionHistory,
|
||||||
onStartNewSession: handleStartNewAgentSession,
|
onStartNewSession: handleStartNewAgentSession,
|
||||||
onLoadHistorySession: handleLoadAgentHistorySession,
|
onLoadHistorySession: handleLoadAgentHistorySession,
|
||||||
@@ -792,12 +914,17 @@ export function MapWorkbenchPage({
|
|||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||||
"--workbench-agent-current-width": `${
|
"--workbench-agent-current-width": `${currentAgentWidth}px`,
|
||||||
artifactActive
|
"--workbench-timeline-height": `${
|
||||||
? WORKBENCH_LAYOUT.collapsedAgentWidth
|
!timelineVisible
|
||||||
: leftPanelOpen
|
? 0
|
||||||
? agentPanelWidth
|
: !isLargeScreen
|
||||||
: WORKBENCH_LAYOUT.collapsedAgentWidth
|
? 48
|
||||||
|
: timelineMode === "expanded"
|
||||||
|
? 240
|
||||||
|
: timelineMode === "summary"
|
||||||
|
? 52
|
||||||
|
: 72
|
||||||
}px`
|
}px`
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -810,6 +937,7 @@ export function MapWorkbenchPage({
|
|||||||
alerts={workbenchAlerts}
|
alerts={workbenchAlerts}
|
||||||
user={user}
|
user={user}
|
||||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||||
|
timelineVisible={timelineVisible}
|
||||||
taskTickerAvailable={taskTickerAvailable}
|
taskTickerAvailable={taskTickerAvailable}
|
||||||
taskTickerVisible={taskTickerVisible}
|
taskTickerVisible={taskTickerVisible}
|
||||||
devPanelEnabled={devPanelEnabled}
|
devPanelEnabled={devPanelEnabled}
|
||||||
@@ -817,6 +945,7 @@ export function MapWorkbenchPage({
|
|||||||
onSelectScenario={handleSelectScenario}
|
onSelectScenario={handleSelectScenario}
|
||||||
onSelectAlert={handleSelectAlert}
|
onSelectAlert={handleSelectAlert}
|
||||||
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||||
|
onToggleTimeline={toggleTimelineVisibility}
|
||||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||||
onPreviewScenario={handlePreviewScenario}
|
onPreviewScenario={handlePreviewScenario}
|
||||||
@@ -841,6 +970,36 @@ export function MapWorkbenchPage({
|
|||||||
onSetArtifactView={artifactWorkspace.setView}
|
onSetArtifactView={artifactWorkspace.setView}
|
||||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||||
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
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={(
|
mapContent={(
|
||||||
<div
|
<div
|
||||||
ref={mapContainerRef}
|
ref={mapContainerRef}
|
||||||
@@ -857,7 +1016,7 @@ export function MapWorkbenchPage({
|
|||||||
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
||||||
<ToolbarPanel {...toolbarPanelProps} />
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
</div>
|
</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">
|
<div className="flex items-end gap-2 pr-2">
|
||||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||||
</div>
|
</div>
|
||||||
@@ -871,8 +1030,6 @@ export function MapWorkbenchPage({
|
|||||||
panelProps={agentPanelProps}
|
panelProps={agentPanelProps}
|
||||||
panelOpen={agent.panelOpen}
|
panelOpen={agent.panelOpen}
|
||||||
panelCollapsing={agent.panelCollapsing}
|
panelCollapsing={agent.panelCollapsing}
|
||||||
artifactActive={artifactActive}
|
|
||||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
|
||||||
personaState={agent.personaState}
|
personaState={agent.personaState}
|
||||||
statusLabel={agent.statusLabel}
|
statusLabel={agent.statusLabel}
|
||||||
viewportWidth={viewportWidth}
|
viewportWidth={viewportWidth}
|
||||||
@@ -882,7 +1039,7 @@ export function MapWorkbenchPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{conditionFeedMounted && !devPanelOpen ? (
|
{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
|
<ScheduledConditionFeed
|
||||||
presentation="desktop-floating"
|
presentation="desktop-floating"
|
||||||
conditions={scheduledConditions}
|
conditions={scheduledConditions}
|
||||||
@@ -942,7 +1099,7 @@ export function MapWorkbenchPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</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 ? (
|
{mobileSheet === null ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -976,7 +1133,13 @@ export function MapWorkbenchPage({
|
|||||||
|
|
||||||
{mobileSheet ? (
|
{mobileSheet ? (
|
||||||
<MobileWorkbenchSheet
|
<MobileWorkbenchSheet
|
||||||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
label={
|
||||||
|
mobileSheet === "agent"
|
||||||
|
? "Agent 工作台抽屉"
|
||||||
|
: mobileSheet === "timeline"
|
||||||
|
? "运行时间轴抽屉"
|
||||||
|
: "工况任务抽屉"
|
||||||
|
}
|
||||||
snap={mobileSheetSnap}
|
snap={mobileSheetSnap}
|
||||||
onSnapChange={setMobileSheetSnap}
|
onSnapChange={setMobileSheetSnap}
|
||||||
onClose={closeMobileSheet}
|
onClose={closeMobileSheet}
|
||||||
@@ -987,6 +1150,18 @@ export function MapWorkbenchPage({
|
|||||||
presentation="mobile-sheet"
|
presentation="mobile-sheet"
|
||||||
onCollapse={closeMobileSheet}
|
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
|
<ScheduledConditionFeed
|
||||||
presentation="mobile-sheet"
|
presentation="mobile-sheet"
|
||||||
@@ -1059,3 +1234,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
|||||||
|
|
||||||
return "当前分辨率";
|
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 小时",
|
scope: "北辰供水分区 · 最近 6 小时",
|
||||||
confidence: "92.6%",
|
confidence: "92.6%",
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
analyticalTimeRange: {
|
||||||
|
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
|
||||||
|
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||||
|
},
|
||||||
preferredView: "map_split",
|
preferredView: "map_split",
|
||||||
mapRelation: "required",
|
mapRelation: "required",
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -93,6 +97,10 @@ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
|||||||
scope: "六个服务分区 · 今日峰值预测",
|
scope: "六个服务分区 · 今日峰值预测",
|
||||||
confidence: "88.4%",
|
confidence: "88.4%",
|
||||||
generatedAt,
|
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",
|
preferredView: "focus",
|
||||||
mapRelation: "none",
|
mapRelation: "none",
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -171,6 +179,10 @@ const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
|||||||
scope: "北辰低压事件 · 30 分钟模拟",
|
scope: "北辰低压事件 · 30 分钟模拟",
|
||||||
confidence: "90.8%",
|
confidence: "90.8%",
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
analyticalTimeRange: {
|
||||||
|
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
|
||||||
|
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||||
|
},
|
||||||
preferredView: "map_split",
|
preferredView: "map_split",
|
||||||
mapRelation: "required",
|
mapRelation: "required",
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -251,3 +263,10 @@ export function createAnalysisArtifactFixture(index: number, instance: number):
|
|||||||
now.toLocaleTimeString("zh-CN", { hour12: false })
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
aria-label={`${artifact.title}分析成果`}
|
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"
|
lang="zh-CN"
|
||||||
>
|
>
|
||||||
<ArtifactHeader artifact={artifact} />
|
<ArtifactHeader artifact={artifact} />
|
||||||
@@ -138,7 +138,7 @@ export function AnalysisArtifactWorkspace({
|
|||||||
|
|
||||||
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||||
return (
|
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="flex items-start justify-between gap-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||||
@@ -190,7 +190,7 @@ function AnalysisBlockView({
|
|||||||
<section
|
<section
|
||||||
aria-labelledby={`${block.id}-title`}
|
aria-labelledby={`${block.id}-title`}
|
||||||
className={cn(
|
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)]",
|
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||||||
spanClass,
|
spanClass,
|
||||||
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
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 }) {
|
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||||||
return (
|
return (
|
||||||
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
|
<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>
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -303,7 +303,7 @@ function ArtifactDetailDrawer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
|
||||||
@@ -355,7 +355,7 @@ function ArtifactActionBar({
|
|||||||
onDestroy: () => void;
|
onDestroy: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
{artifact.mapRelation !== "none" ? (
|
{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">
|
<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 }) {
|
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
<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>
|
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||||
|
|||||||
@@ -17,21 +17,21 @@ export function ArtifactPeekBar({
|
|||||||
<aside
|
<aside
|
||||||
aria-label="Agent 成果预览"
|
aria-label="Agent 成果预览"
|
||||||
className={cn(
|
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)]",
|
"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",
|
||||||
"bottom-16 left-1/2 -translate-x-1/2",
|
"left-1/2 -translate-x-1/2",
|
||||||
surfaceMode === "map_split" && "2xl:left-[25%]"
|
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" />
|
<FileChartColumnIncreasing size={21} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<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" />
|
<Sparkles size={12} aria-hidden="true" />
|
||||||
新分析成果 · R{artifact.revision}
|
新分析成果 · R{artifact.revision}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 truncate text-sm font-semibold text-slate-50">{artifact.title}</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-400">{artifact.summary}</p>
|
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -46,7 +46,7 @@ export function ArtifactPeekBar({
|
|||||||
aria-label="销毁预览成果"
|
aria-label="销毁预览成果"
|
||||||
title="销毁预览成果"
|
title="销毁预览成果"
|
||||||
onClick={onDestroy}
|
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" />
|
<Trash2 size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type ArtifactRequestedView
|
type ArtifactRequestedView
|
||||||
} from "./workspace-model";
|
} from "./workspace-model";
|
||||||
|
|
||||||
export function useArtifactWorkspace(viewportWidth: number) {
|
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
|
||||||
const [state, setState] = useState(createInitialArtifactWorkspaceState);
|
const [state, setState] = useState(createInitialArtifactWorkspaceState);
|
||||||
const fixtureIndexRef = useRef(0);
|
const fixtureIndexRef = useRef(0);
|
||||||
const fixtureInstanceRef = useRef(0);
|
const fixtureInstanceRef = useRef(0);
|
||||||
@@ -49,14 +49,14 @@ export function useArtifactWorkspace(viewportWidth: number) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const surfaceMode = useMemo(
|
const surfaceMode = useMemo(
|
||||||
() => resolveWorkbenchSurfaceMode(state, viewportWidth),
|
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
|
||||||
[state, viewportWidth]
|
[businessWorkspaceWidth, state]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
surfaceMode,
|
surfaceMode,
|
||||||
mapSplitAvailable: viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||||
createTestArtifactPreview,
|
createTestArtifactPreview,
|
||||||
openPreview,
|
openPreview,
|
||||||
collapseArtifact,
|
collapseArtifact,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
type WorkbenchMainFrameProps = {
|
type WorkbenchMainFrameProps = {
|
||||||
mapContent: ReactNode;
|
mapContent: ReactNode;
|
||||||
mapOverlay?: ReactNode;
|
mapOverlay?: ReactNode;
|
||||||
|
timelineContent: ReactNode;
|
||||||
|
timelineMode: "compact" | "expanded" | "summary";
|
||||||
activeArtifact: AnalysisArtifact | null;
|
activeArtifact: AnalysisArtifact | null;
|
||||||
pendingArtifact: AnalysisArtifact | null;
|
pendingArtifact: AnalysisArtifact | null;
|
||||||
surfaceMode: WorkbenchSurfaceMode;
|
surfaceMode: WorkbenchSurfaceMode;
|
||||||
@@ -26,6 +28,8 @@ type WorkbenchMainFrameProps = {
|
|||||||
export function WorkbenchMainFrame({
|
export function WorkbenchMainFrame({
|
||||||
mapContent,
|
mapContent,
|
||||||
mapOverlay,
|
mapOverlay,
|
||||||
|
timelineContent,
|
||||||
|
timelineMode,
|
||||||
activeArtifact,
|
activeArtifact,
|
||||||
pendingArtifact,
|
pendingArtifact,
|
||||||
surfaceMode,
|
surfaceMode,
|
||||||
@@ -41,8 +45,9 @@ export function WorkbenchMainFrame({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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-testid="workbench-main-frame"
|
||||||
|
data-timeline-mode={timelineMode}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id="main-workspace"
|
id="main-workspace"
|
||||||
@@ -94,16 +99,27 @@ export function WorkbenchMainFrame({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{pendingArtifact ? (
|
||||||
|
<ArtifactPeekBar
|
||||||
|
artifact={pendingArtifact}
|
||||||
|
surfaceMode={surfaceMode}
|
||||||
|
onOpen={onOpenPendingArtifact}
|
||||||
|
onDestroy={onDestroyPendingArtifact}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingArtifact ? (
|
<div
|
||||||
<ArtifactPeekBar
|
className={cn(
|
||||||
artifact={pendingArtifact}
|
"pointer-events-auto absolute bottom-3 left-3 right-3 z-40 mx-auto min-h-0 w-[calc(100%-1.5rem)]",
|
||||||
surfaceMode={surfaceMode}
|
timelineMode === "expanded" && "max-w-[1180px]",
|
||||||
onOpen={onOpenPendingArtifact}
|
timelineMode === "compact" && "max-w-[980px]",
|
||||||
onDestroy={onDestroyPendingArtifact}
|
timelineMode === "summary" && "max-w-[760px]"
|
||||||
/>
|
)}
|
||||||
) : null}
|
>
|
||||||
|
{timelineContent}
|
||||||
|
</div>
|
||||||
</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 ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||||
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||||
@@ -88,6 +89,7 @@ export type AnalysisArtifact = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
confidence: string;
|
confidence: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
|
analyticalTimeRange: { start: string; end: string };
|
||||||
preferredView: ArtifactViewMode;
|
preferredView: ArtifactViewMode;
|
||||||
mapRelation: ArtifactMapRelation;
|
mapRelation: ArtifactMapRelation;
|
||||||
blocks: AnalysisBlock[];
|
blocks: AnalysisBlock[];
|
||||||
@@ -155,13 +157,13 @@ export function requestArtifactView(
|
|||||||
|
|
||||||
export function resolveWorkbenchSurfaceMode(
|
export function resolveWorkbenchSurfaceMode(
|
||||||
state: ArtifactWorkspaceState,
|
state: ArtifactWorkspaceState,
|
||||||
viewportWidth: number
|
businessWorkspaceWidth: number
|
||||||
): WorkbenchSurfaceMode {
|
): WorkbenchSurfaceMode {
|
||||||
const artifact = state.activeArtifact;
|
const artifact = state.activeArtifact;
|
||||||
if (!artifact) return "default";
|
if (!artifact) return "default";
|
||||||
if (state.requestedView === "map_only") return "map_only";
|
if (state.requestedView === "map_only") return "map_only";
|
||||||
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
|
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 {
|
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user