Files
next-tjwater-frontend/src/features/workbench/workspace/workbench-main-frame.tsx
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

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