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