311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
|
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { useState, type ReactNode } from "react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { ReadingWorkspaceContent } from "@/shared/ui/reading-workspace";
|
|
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
|
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
|
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
|
import { WorkbenchContentSplit } from "./workbench-content-split";
|
|
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model";
|
|
import {
|
|
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
|
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
|
} from "./artifact-view-control-styles";
|
|
import type {
|
|
AnalysisArtifact,
|
|
ArtifactRequestedView,
|
|
WorkbenchSurfaceMode
|
|
} from "./workspace-model";
|
|
|
|
type WorkbenchMainFrameProps = {
|
|
mapContent: ReactNode;
|
|
mapOverlay?: ReactNode;
|
|
timelineContent: ReactNode;
|
|
timelineMode: "compact" | "expanded" | "summary";
|
|
timelineBottom: number;
|
|
activeArtifact: AnalysisArtifact | null;
|
|
pendingArtifact: AnalysisArtifact | null;
|
|
surfaceMode: WorkbenchSurfaceMode;
|
|
mapSplitAvailable: boolean;
|
|
readingSplitAvailable?: boolean;
|
|
onOpenPendingArtifact: () => void;
|
|
onDestroyPendingArtifact: () => void;
|
|
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
|
onCollapseArtifact: () => void;
|
|
onDestroyArtifact: () => void;
|
|
};
|
|
|
|
export function WorkbenchMainFrame({
|
|
mapContent,
|
|
mapOverlay,
|
|
timelineContent,
|
|
timelineMode,
|
|
timelineBottom,
|
|
activeArtifact,
|
|
pendingArtifact,
|
|
surfaceMode: artifactSurfaceMode,
|
|
mapSplitAvailable,
|
|
readingSplitAvailable = false,
|
|
onOpenPendingArtifact,
|
|
onDestroyPendingArtifact,
|
|
onSetArtifactView,
|
|
onCollapseArtifact,
|
|
onDestroyArtifact
|
|
}: WorkbenchMainFrameProps) {
|
|
const reading = useReadingWorkspace();
|
|
const surfaceMode = reading?.active
|
|
? reading.view === "map_split" && !readingSplitAvailable
|
|
? "focus"
|
|
: reading.view
|
|
: artifactSurfaceMode;
|
|
const prefersReducedMotion = useReducedMotion();
|
|
const showMap = surfaceMode !== "focus";
|
|
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only");
|
|
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active);
|
|
const mapOnly = surfaceMode === "map_only";
|
|
const mapSplit = surfaceMode === "map_split";
|
|
const [mapPercentage, setMapPercentage] = useState(50);
|
|
const viewControlLabel = mapOnly
|
|
? "返回分析成果"
|
|
: mapSplit
|
|
? "专注分析"
|
|
: mapSplitAvailable
|
|
? "半屏显示"
|
|
: "显示地图";
|
|
const viewControlLeft = mapSplit ? `calc(${mapPercentage}% + 24px)` : "24px";
|
|
const timelineTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
|
const viewControlTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
|
|
const artifactTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
|
|
|
// Map imagery fills the header backdrop; documents and controls reserve the header inset.
|
|
return (
|
|
<div
|
|
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
|
|
data-testid="workbench-main-frame"
|
|
data-timeline-mode={timelineMode}
|
|
>
|
|
<div
|
|
id="main-workspace"
|
|
data-layout-mode={surfaceMode}
|
|
className="pointer-events-auto absolute inset-0 min-h-0 overflow-hidden bg-[#dbe3eb]"
|
|
>
|
|
<WorkbenchContentSplit
|
|
split={mapSplit}
|
|
showMap={showMap}
|
|
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
|
|
onMapSizeChange={setMapPercentage}
|
|
map={
|
|
<section
|
|
id="workspace-map"
|
|
aria-label="供水管网地图工作区"
|
|
aria-hidden={!showMap}
|
|
className={cn(
|
|
"relative h-full min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
|
!showMap && "hidden"
|
|
)}
|
|
>
|
|
{mapContent}
|
|
{mapOverlay}
|
|
</section>
|
|
}
|
|
document={
|
|
<>
|
|
<AnimatePresence initial={false} mode="popLayout">
|
|
{activeArtifact ? (
|
|
<motion.section
|
|
key={activeArtifact.id}
|
|
id="artifact-workspace"
|
|
aria-label={`${activeArtifact.title}工作区`}
|
|
aria-hidden={!showArtifact}
|
|
className={cn(
|
|
"absolute inset-x-0 bottom-0 top-14 z-30 min-h-0 min-w-0 overflow-hidden",
|
|
surfaceMode === "focus" && "lg:pl-[84px]",
|
|
!showArtifact && "pointer-events-none"
|
|
)}
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
|
animate={
|
|
showArtifact
|
|
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
|
: {
|
|
opacity: 0,
|
|
y: 8,
|
|
scale: 0.994,
|
|
transitionEnd: { visibility: "hidden" }
|
|
}
|
|
}
|
|
exit={
|
|
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }
|
|
}
|
|
transition={artifactTransition}
|
|
>
|
|
<AnalysisArtifactWorkspace
|
|
artifact={activeArtifact}
|
|
onCollapse={onCollapseArtifact}
|
|
onDestroy={onDestroyArtifact}
|
|
/>
|
|
</motion.section>
|
|
) : null}
|
|
</AnimatePresence>
|
|
|
|
{reading?.active ? (
|
|
<section
|
|
aria-label="展开报告工作区"
|
|
className={cn(
|
|
"agent-report-workspace surface-dock absolute inset-x-0 bottom-0 top-14 z-30 flex min-h-0 min-w-0 flex-col",
|
|
reading.view === "map_only" && "hidden"
|
|
)}
|
|
>
|
|
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[11px] text-muted-foreground">报告与分析</p>
|
|
<h2 className="break-words text-sm font-semibold">{reading.active.title}</h2>
|
|
</div>
|
|
{readingSplitAvailable ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")
|
|
}
|
|
>
|
|
{surfaceMode === "map_split" ? <Focus size={14} /> : <MapIcon size={14} />}
|
|
{surfaceMode === "map_split" ? "专注阅读" : "地图对照"}
|
|
</Button>
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={() => reading.setView("map_only")}>
|
|
<MapIcon size={14} />
|
|
查看地图
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="收起展开报告"
|
|
onClick={() => reading.close()}
|
|
>
|
|
<X size={16} />
|
|
</Button>
|
|
</header>
|
|
<div
|
|
data-reading-scroll
|
|
className="surface-reading min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-24 md:px-6 md:pb-6"
|
|
>
|
|
<div className="mx-auto max-w-5xl pt-4 md:pt-6">
|
|
<ReadingWorkspaceContent />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
{reading?.active && reading.view === "map_only" ? (
|
|
<Button
|
|
className="absolute bottom-24 left-4 z-40 gap-2"
|
|
onClick={() => reading.setView("focus")}
|
|
>
|
|
<ArrowLeft size={15} />
|
|
返回报告
|
|
</Button>
|
|
) : null}
|
|
|
|
{pendingArtifact && !reading?.active ? (
|
|
<ArtifactPeekBar
|
|
artifact={pendingArtifact}
|
|
surfaceMode={surfaceMode}
|
|
onOpen={onOpenPendingArtifact}
|
|
onDestroy={onDestroyPendingArtifact}
|
|
/>
|
|
) : null}
|
|
|
|
<AnimatePresence initial={false}>
|
|
{showArtifactViewControl ? (
|
|
<motion.div
|
|
className={cn(
|
|
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
|
|
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
|
)}
|
|
style={{ left: viewControlLeft }}
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 6, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
|
transition={viewControlTransition}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={viewControlLabel}
|
|
onClick={() =>
|
|
onSetArtifactView(
|
|
surfaceMode === "focus"
|
|
? mapSplitAvailable
|
|
? "map_split"
|
|
: "map_only"
|
|
: "focus"
|
|
)
|
|
}
|
|
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
|
|
>
|
|
<AnimatePresence initial={false} mode="wait">
|
|
<motion.span
|
|
key={surfaceMode}
|
|
className="inline-flex items-center gap-1.5"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={
|
|
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }
|
|
}
|
|
transition={viewControlTransition}
|
|
>
|
|
{mapOnly ? (
|
|
<ArrowLeft size={13} aria-hidden="true" />
|
|
) : mapSplit ? (
|
|
<Focus size={13} aria-hidden="true" />
|
|
) : (
|
|
<MapIcon size={13} aria-hidden="true" />
|
|
)}
|
|
{viewControlLabel}
|
|
</motion.span>
|
|
</AnimatePresence>
|
|
</button>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
|
|
initial={false}
|
|
animate={{
|
|
bottom: timelineBottom,
|
|
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
|
right: 12
|
|
}}
|
|
transition={timelineTransition}
|
|
>
|
|
<AnimatePresence initial={false}>
|
|
{timelineContent && !reading?.active ? (
|
|
<motion.div
|
|
key="operational-timeline"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
|
|
transition={timelineTransition}
|
|
>
|
|
{timelineContent}
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|