feat: add adaptive agent workspace windows

This commit is contained in:
2026-08-19 12:57:37 +08:00
parent d08cf2abc1
commit f58e0a70f4
18 changed files with 1797 additions and 309 deletions
@@ -0,0 +1,324 @@
import {
Bot,
CalendarClock,
ChevronsLeft,
ChevronsRight,
FlaskConical,
Layers3,
Map as MapIcon,
PanelLeft,
SlidersHorizontal,
Sparkles
} from "lucide-react";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { AnalysisDocumentView } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
import {
MAX_ANALYSIS_WINDOWS,
closeAnalysisWindow,
createInitialWorkspaceState,
focusWorkspaceWindow,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
updateWorkspaceWindowRect,
type WorkspaceBounds
} from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window";
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
type WorkbenchMainFrameProps = {
renderAgentPanel: (onCollapse: () => void) => ReactNode;
conditionsPanel: ReactNode;
layersPanel: ReactNode;
toolsPanel: ReactNode;
mapContent: ReactNode;
mapOverlay?: ReactNode;
testEnabled: boolean;
activeSection: WorkbenchNavigationSection;
navigationCollapsed: boolean;
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
onNavigationCollapsedChange: (collapsed: boolean) => void;
};
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", icon: Bot },
{ id: "conditions", label: "工况任务", icon: CalendarClock },
{ id: "layers", label: "图层", icon: Layers3 },
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
];
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
export function WorkbenchMainFrame({
renderAgentPanel,
conditionsPanel,
layersPanel,
toolsPanel,
mapContent,
mapOverlay,
testEnabled,
activeSection,
navigationCollapsed,
onActiveSectionChange,
onNavigationCollapsedChange
}: WorkbenchMainFrameProps) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
const [desktop, setDesktop] = useState(false);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
useEffect(() => {
const element = workspaceRef.current;
if (!element) return;
const updateBounds = () => {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const nextBounds = { width: rect.width, height: rect.height };
setBounds(nextBounds);
setWorkspace((current) => resizeWorkspace(current, nextBounds));
}
};
updateBounds();
const observer = new ResizeObserver(updateBounds);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setDesktop(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const openTestDocument = useCallback(() => {
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
showMapNotice({
tone: "warning",
title: "临时窗口已达上限",
message: "请关闭一个 Agent 分析窗口后继续测试。"
});
return;
}
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]);
const navigationWidth = navigationCollapsed ? 48 : 368;
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
data-testid="workbench-main-frame"
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
>
<aside
aria-label="工作台导航"
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
style={{ width: navigationWidth }}
>
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
{NAVIGATION_ITEMS.map((item) => {
const Icon = item.icon;
const active = item.id === activeSection;
return (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={active}
title={item.label}
className={cn(
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
)}
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
>
<Icon size={19} aria-hidden="true" />
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
</button>
);
})}
<div className="mt-auto flex flex-col items-center gap-1">
{testEnabled ? (
<button
type="button"
aria-label="测试打开 Agent 多图表窗口"
title="测试打开 Agent 多图表窗口"
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={openTestDocument}
>
<FlaskConical size={19} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
>
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
</button>
</div>
</nav>
<div
aria-hidden={navigationCollapsed}
className={cn(
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
navigationCollapsed && "invisible"
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
</div>
{testEnabled ? (
<button
type="button"
onClick={openTestDocument}
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<Sparkles size={14} aria-hidden="true" />
</button>
) : null}
</header>
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
{desktop ? (
<>
<NavigationPanel active={activeSection === "agent"}>
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
</NavigationPanel>
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
</>
) : null}
</div>
</div>
</aside>
<div
ref={workspaceRef}
id="main-workspace"
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
>
<WorkspaceWindow
id={workspace.map.id}
title={workspace.map.title}
mode={desktop ? workspace.map.mode : "docked"}
rect={workspace.map.rect}
zIndex={workspace.map.zIndex}
active={workspace.activeWindowId === workspace.map.id}
kind="map"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
>
{mapContent}
{mapOverlay}
</WorkspaceWindow>
{workspace.analyses.map((window) => (
<WorkspaceWindow
key={window.id}
id={window.id}
title={window.title}
mode={window.mode}
rect={window.rect}
zIndex={window.zIndex}
active={workspace.activeWindowId === window.id}
kind="analysis"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
>
<AnalysisDocumentView document={window.document} />
</WorkspaceWindow>
))}
</div>
<div
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
role="toolbar"
aria-label="工作区窗口任务栏"
>
<TaskbarButton
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
minimized={workspace.map.mode === "minimized"}
icon={<MapIcon size={15} aria-hidden="true" />}
label="供水管网地图"
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
key={window.id}
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
minimized={window.mode === "minimized"}
icon={<Sparkles size={14} aria-hidden="true" />}
label={window.title}
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
/>
))}
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} </span>
</div>
{!desktop ? <span className="sr-only"></span> : null}
</div>
);
}
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
title={label}
className={cn(
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
)}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
return (
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
{children}
</div>
);
}