feat: redesign floating workspace interactions

This commit is contained in:
2026-08-19 14:12:08 +08:00
parent 1068304da5
commit be7b4149f2
9 changed files with 210 additions and 281 deletions
@@ -42,7 +42,7 @@ export function WorkbenchAgentPanels({
return ( return (
<div <div
ref={resize.panelRef} ref={resize.panelRef}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block" className="pointer-events-none absolute bottom-14 left-3 top-24 z-50 hidden max-w-[720px] lg:block"
style={{ width: resize.width ?? defaultWidth }} style={{ width: resize.width ?? defaultWidth }}
> >
{panelOpen ? ( {panelOpen ? (
@@ -7,6 +7,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
FlaskConical, FlaskConical,
Sparkles,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
@@ -40,6 +41,7 @@ export type WorkbenchTopBarProps = {
onCompareScenario: () => void; onCompareScenario: () => void;
onExportScenarioReport: () => void; onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
onShowDataStatus: () => void; onShowDataStatus: () => void;
onRefreshTiles: () => void; onRefreshTiles: () => void;
onShowShortcuts: () => void; onShowShortcuts: () => void;
@@ -70,6 +72,7 @@ export function WorkbenchTopBar({
onCompareScenario, onCompareScenario,
onExportScenarioReport, onExportScenarioReport,
onAnalyzeAlertsWithAgent, onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
onShowDataStatus, onShowDataStatus,
onRefreshTiles, onRefreshTiles,
onShowShortcuts, onShowShortcuts,
@@ -141,6 +144,23 @@ export function WorkbenchTopBar({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{onCreateTestAnalysis ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="测试打开 Agent 多图表窗口"
title="测试打开 Agent 多图表窗口"
data-selection-tone="soft"
onClick={onCreateTestAnalysis}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Sparkles size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}></span>
</button>
</div>
) : null}
{devPanelEnabled ? ( {devPanelEnabled ? (
<div className="hidden lg:block"> <div className="hidden lg:block">
<button <button
+50 -65
View File
@@ -35,6 +35,7 @@ import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; 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 { import {
ToolbarPanel, ToolbarPanel,
type ExportViewPreset, type ExportViewPreset,
@@ -44,7 +45,7 @@ import {
import { WorkbenchTopBar } from "./components/workbench-top-bar"; import { WorkbenchTopBar } from "./components/workbench-top-bar";
import { import {
WorkbenchMainFrame, WorkbenchMainFrame,
type WorkbenchNavigationSection type WorkbenchMainFrameHandle
} from "./workspace/workbench-main-frame"; } from "./workspace/workbench-main-frame";
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";
@@ -103,9 +104,8 @@ export function MapWorkbenchPage({
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mainFrameRef = useRef<WorkbenchMainFrameHandle | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [mainFrameSection, setMainFrameSection] = useState<WorkbenchNavigationSection>("agent");
const [mainFrameNavigationCollapsed, setMainFrameNavigationCollapsed] = useState(false);
const [devPanelOpen, setDevPanelOpen] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null); const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
@@ -154,6 +154,7 @@ export function MapWorkbenchPage({
agentPanelWidth, agentPanelWidth,
closeMobileSheet, closeMobileSheet,
conditionFeedExpanded, conditionFeedExpanded,
conditionFeedMounted,
handleConditionExpandedChange, handleConditionExpandedChange,
isLargeScreen, isLargeScreen,
leftPanelOpen, leftPanelOpen,
@@ -163,8 +164,11 @@ export function MapWorkbenchPage({
openConditionFeedForViewport, openConditionFeedForViewport,
rightPanelExpanded, rightPanelExpanded,
rightPanelOpen, rightPanelOpen,
setAgentPanelWidth,
setMobileSheetSnap, setMobileSheetSnap,
toggleConditionFeedForViewport shouldShowConditionFeed,
toggleConditionFeedForViewport,
viewportWidth
} = useWorkbenchResponsiveLayout({ } = useWorkbenchResponsiveLayout({
activeToolOpen: activeToolId !== null, activeToolOpen: activeToolId !== null,
agentPanelOpen: agent.panelOpen, agentPanelOpen: agent.panelOpen,
@@ -175,27 +179,14 @@ export function MapWorkbenchPage({
}); });
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
if (isLargeScreen) {
setMainFrameSection("agent");
setMainFrameNavigationCollapsed(false);
}
openAgentPanelForViewport(); openAgentPanelForViewport();
} }
function openConditionWorkspaceForViewport() { function openConditionWorkspaceForViewport() {
if (isLargeScreen) {
setMainFrameSection("conditions");
setMainFrameNavigationCollapsed(false);
}
openConditionFeedForViewport(); openConditionFeedForViewport();
} }
function toggleConditionWorkspaceForViewport() { function toggleConditionWorkspaceForViewport() {
if (isLargeScreen) {
setMainFrameSection((current) => current === "conditions" ? "agent" : "conditions");
setMainFrameNavigationCollapsed(false);
return;
}
toggleConditionFeedForViewport(); toggleConditionFeedForViewport();
} }
@@ -208,9 +199,9 @@ export function MapWorkbenchPage({
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef, mapRef,
mapReady, mapReady,
leftPanelOpen: isLargeScreen ? false : leftPanelOpen, leftPanelOpen,
rightPanelOpen: isLargeScreen ? devPanelOpen : rightPanelOpen, rightPanelOpen,
conditionPanelExpanded: isLargeScreen ? false : rightPanelExpanded, conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth agentPanelWidth
}); });
@@ -627,9 +618,9 @@ 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,
isLargeScreen ? false : leftPanelOpen, leftPanelOpen,
isLargeScreen ? devPanelOpen : rightPanelOpen, rightPanelOpen,
isLargeScreen ? false : rightPanelExpanded, rightPanelExpanded,
agentPanelWidth agentPanelWidth
), ),
duration: trustedAction.durationMs ?? 500 duration: trustedAction.durationMs ?? 500
@@ -797,7 +788,7 @@ export function MapWorkbenchPage({
activeScenarioId={activeScenarioId} activeScenarioId={activeScenarioId}
alerts={workbenchAlerts} alerts={workbenchAlerts}
user={user} user={user}
conditionFeedVisible={isLargeScreen ? mainFrameSection === "conditions" && !mainFrameNavigationCollapsed : mobileSheet === "condition"} conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
taskTickerAvailable={taskTickerAvailable} taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible} taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled} devPanelEnabled={devPanelEnabled}
@@ -811,6 +802,7 @@ export function MapWorkbenchPage({
onCompareScenario={handleCompareScenario} onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport} onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent} onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={devPanelEnabled ? () => mainFrameRef.current?.openTestAnalysis() : undefined}
onShowDataStatus={handleShowDataStatus} onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles} onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts} onShowShortcuts={handleShowShortcuts}
@@ -819,47 +811,7 @@ export function MapWorkbenchPage({
/> />
<WorkbenchMainFrame <WorkbenchMainFrame
testEnabled={devPanelEnabled} ref={mainFrameRef}
activeSection={mainFrameSection}
navigationCollapsed={mainFrameNavigationCollapsed}
onActiveSectionChange={setMainFrameSection}
onNavigationCollapsedChange={setMainFrameNavigationCollapsed}
renderAgentPanel={(onCollapse) => (
<AgentCommandPanel
{...agentPanelProps}
presentation="desktop-dock"
onCollapse={onCollapse}
/>
)}
conditionsPanel={(
<ScheduledConditionFeed
presentation="desktop-dock"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
)}
layersPanel={(
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
</div>
)}
toolsPanel={(
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
</div>
)}
mapContent={( mapContent={(
<div <div
ref={mapContainerRef} ref={mapContainerRef}
@@ -886,6 +838,39 @@ export function MapWorkbenchPage({
)} )}
/> />
<WorkbenchAgentPanels
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
viewportWidth={viewportWidth}
onCollapsePanel={agent.collapsePanel}
onExpandPanel={agent.expandPanel}
onWidthCommit={setAgentPanelWidth}
/>
{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)]">
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible={shouldShowConditionFeed}
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
{devPanelEnabled && devPanelOpen ? ( {devPanelEnabled && devPanelOpen ? (
<MapDevPanel <MapDevPanel
commands={mapController} commands={mapController}
@@ -10,7 +10,7 @@ import type {
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) { export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
return ( return (
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN"> <article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm"> <header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm lg:pl-16">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700"> <div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" /> <Sparkles size={14} aria-hidden="true" />
@@ -1,26 +1,20 @@
import { import {
Bot,
CalendarClock,
ChevronsLeft,
ChevronsRight,
Columns2, Columns2,
FlaskConical,
Grid2X2, Grid2X2,
Layers3, Layers3,
LayoutDashboard, LayoutDashboard,
LayoutTemplate, LayoutTemplate,
Map as MapIcon, Map as MapIcon,
PanelLeft,
PanelsTopLeft, PanelsTopLeft,
SlidersHorizontal,
Sparkles Sparkles
} from "lucide-react"; } from "lucide-react";
import { import {
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle,
useRef, useRef,
useState, useState,
type CSSProperties,
type ReactNode type ReactNode
} from "react"; } from "react";
import { showMapNotice } from "@/features/map/core"; import { showMapNotice } from "@/features/map/core";
@@ -58,28 +52,14 @@ import {
} from "./workspace-model"; } from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window"; import { WorkspaceWindow } from "./workspace-window";
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
type WorkbenchMainFrameProps = { type WorkbenchMainFrameProps = {
renderAgentPanel: (onCollapse: () => void) => ReactNode;
conditionsPanel: ReactNode;
layersPanel: ReactNode;
toolsPanel: ReactNode;
mapContent: ReactNode; mapContent: ReactNode;
mapOverlay?: 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 }> = [ export type WorkbenchMainFrameHandle = {
{ id: "agent", label: "Agent", icon: Bot }, openTestAnalysis: () => void;
{ id: "conditions", label: "工况任务", icon: CalendarClock }, };
{ id: "layers", label: "图层", icon: Layers3 },
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
];
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 }; const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
@@ -89,19 +69,10 @@ type WorkspaceDragState = {
targetId: string | null; targetId: string | null;
}; };
export function WorkbenchMainFrame({ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
renderAgentPanel,
conditionsPanel,
layersPanel,
toolsPanel,
mapContent, mapContent,
mapOverlay, mapOverlay
testEnabled, }, ref) {
activeSection,
navigationCollapsed,
onActiveSectionChange,
onNavigationCollapsedChange
}: WorkbenchMainFrameProps) {
const workspaceRef = useRef<HTMLDivElement | null>(null); const workspaceRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<WorkspaceDragState | null>(null); const dragStateRef = useRef<WorkspaceDragState | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS); const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
@@ -154,6 +125,8 @@ export function WorkbenchMainFrame({
setWorkspace((current) => openAnalysisDocument(current, document, bounds)); setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]); }, [bounds, workspace.analyses.length]);
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => { const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
const root = workspaceRef.current; const root = workspaceRef.current;
if (!root) return null; if (!root) return null;
@@ -215,110 +188,16 @@ export function WorkbenchMainFrame({
setWorkspaceAnnouncement("已取消窗口移动"); setWorkspaceAnnouncement("已取消窗口移动");
}, []); }, []);
const navigationWidth = navigationCollapsed ? 48 : 368;
return ( return (
<div <div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14" className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
data-testid="workbench-main-frame" 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 <div
ref={workspaceRef} ref={workspaceRef}
id="main-workspace" id="main-workspace"
data-layout-mode={workspace.layout.mode} data-layout-mode={workspace.layout.mode}
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]" className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
> >
<WorkspaceWindow <WorkspaceWindow
id={workspace.map.id} id={workspace.map.id}
@@ -371,7 +250,7 @@ export function WorkbenchMainFrame({
</div> </div>
<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" className="pointer-events-auto absolute bottom-0 left-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:flex"
role="toolbar" role="toolbar"
aria-label="工作区窗口任务栏" aria-label="工作区窗口任务栏"
> >
@@ -410,7 +289,7 @@ export function WorkbenchMainFrame({
{!desktop ? <span className="sr-only"></span> : null} {!desktop ? <span className="sr-only"></span> : null}
</div> </div>
); );
} });
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) { function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return ( return (
@@ -520,11 +399,3 @@ function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
if (windowId === state.map.id) return state.map.title; if (windowId === state.map.id) return state.map.title;
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口"; return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
} }
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
return (
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
{children}
</div>
);
}
@@ -42,11 +42,11 @@ function visibleIds(state: WorkspaceState) {
} }
describe("workspace window lifecycle", () => { describe("workspace window lifecycle", () => {
it("minimizes the map for the first analysis and restores it after the last window closes", () => { it("keeps the map visible under the first analysis and restores it after the last window closes", () => {
const initial = createInitialWorkspaceState(bounds); const initial = createInitialWorkspaceState(bounds);
const opened = openAnalysisDocument(initial, documentAt(1), bounds); const opened = openAnalysisDocument(initial, documentAt(1), bounds);
expect(opened.map.mode).toBe("minimized"); expect(opened.map.mode).toBe("docked");
expect(opened.analyses).toHaveLength(1); expect(opened.analyses).toHaveLength(1);
const closed = closeAnalysisWindow(opened, "analysis-1"); const closed = closeAnalysisWindow(opened, "analysis-1");
@@ -174,7 +174,7 @@ export function openAnalysisDocument(
}; };
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds); if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return { ...opened, map: { ...opened.map, mode: "minimized", minimizedByLayout: false } }; return opened;
} }
export function closeAnalysisWindow( export function closeAnalysisWindow(
@@ -1,9 +1,8 @@
import { import {
Copy, Grip,
Maximize2, Maximize2,
Minimize2, Minimize2,
Minus, Minus,
PanelTopClose,
X X
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -14,6 +13,13 @@ import {
type ReactNode type ReactNode
} from "react"; } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model"; import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
@@ -85,9 +91,20 @@ export function WorkspaceWindow({
action: "move" | ResizeDirection action: "move" | ResizeDirection
) => { ) => {
const moving = action === "move"; const moving = action === "move";
if ((!moving && !floating) || (moving && mode !== "floating" && mode !== "docked") || event.button !== 0) return; if (
if (event.target instanceof Element && event.target.closest("button")) return; (!moving && !floating)
|| (moving && mode !== "floating" && mode !== "docked" && mode !== "maximized")
|| event.button !== 0
) return;
if (
event.target instanceof Element
&& (
(!moving && event.target.closest("button"))
|| (moving && event.target.closest("[data-window-operation]"))
)
) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
onFocus(); onFocus();
const startX = event.clientX; const startX = event.clientX;
const startY = event.clientY; const startY = event.clientY;
@@ -105,7 +122,7 @@ export function WorkspaceWindow({
if (!moved && Math.hypot(deltaX, deltaY) < 3) return; if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
moved = true; moved = true;
if (moving) { if (moving) {
const nextRect = mode === "docked" && workspaceBounds const nextRect = mode !== "floating" && workspaceBounds
? { ? {
...startRect, ...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40), x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
@@ -132,7 +149,7 @@ export function WorkspaceWindow({
setDragRect(null); setDragRect(null);
return; return;
} }
const finalRect = mode === "docked" && workspaceBounds const finalRect = mode !== "floating" && workspaceBounds
? { ? {
...startRect, ...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40), x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
@@ -160,7 +177,7 @@ export function WorkspaceWindow({
if (moving) window.addEventListener("keydown", handleKeyDown); if (moving) window.addEventListener("keydown", handleKeyDown);
}; };
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { const handleOperationKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!floating || !event.key.startsWith("Arrow")) return; if (!floating || !event.key.startsWith("Arrow")) return;
event.preventDefault(); event.preventDefault();
const step = event.altKey ? 2 : 12; const step = event.altKey ? 2 : 12;
@@ -173,6 +190,11 @@ export function WorkspaceWindow({
); );
}; };
const handleWindowPointerDownCapture = (event: ReactPointerEvent<HTMLElement>) => {
onFocus();
if (event.altKey) beginPointerAction(event, "move");
};
return ( return (
<section <section
ref={sectionRef} ref={sectionRef}
@@ -180,46 +202,67 @@ export function WorkspaceWindow({
data-workspace-window-id={id} data-workspace-window-id={id}
aria-label={title} aria-label={title}
className={cn( className={cn(
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none", "group/workspace-window pointer-events-auto absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex", mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
mode === "docked" && !dragRect ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]", (mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
swapTarget swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
? "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
: active
? "lg:ring-1 lg:ring-blue-500/40"
: "lg:ring-1 lg:ring-slate-300/90"
)} )}
style={style} style={style}
onPointerDownCapture={onFocus} onPointerDownCapture={handleWindowPointerDownCapture}
> >
<header
tabIndex={0}
className={cn(
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
(floating || mode === "docked") && "cursor-move"
)}
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
onKeyDown={handleTitleKeyDown}
onPointerDown={(event) => beginPointerAction(event, "move")}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-md", kind === "map" ? "bg-blue-600 text-white" : "bg-slate-900 text-white")}>
{kind === "map" ? <PanelTopClose size={15} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{title}</p>
</div>
<div className="flex shrink-0 items-center">
<WindowButton label={`最小化${title}`} onClick={onMinimize}><Minus size={16} aria-hidden="true" /></WindowButton>
{mode === "maximized" || mode === "docked" ? (
<WindowButton label={`浮动${title}`} onClick={onRestore}><Minimize2 size={15} aria-hidden="true" /></WindowButton>
) : (
<WindowButton label={`最大化${title}`} onClick={onMaximize}><Maximize2 size={15} aria-hidden="true" /></WindowButton>
)}
{onClose ? <WindowButton label={`关闭并销毁${title}`} danger onClick={onClose}><X size={16} aria-hidden="true" /></WindowButton> : null}
</div>
</header>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div> <div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{active ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
data-window-operation
aria-label={`打开${title}窗口操作`}
title={`${title}窗口操作`}
className="absolute left-2 top-2 z-40 hidden h-10 w-10 place-items-center rounded-md bg-slate-950/84 text-white opacity-0 shadow-[0_6px_18px_rgba(15,23,42,0.24)] transition-[opacity,background-color,scale] duration-150 [@media(hover:hover)]:group-hover/workspace-window:grid [@media(hover:hover)]:group-hover/workspace-window:opacity-100 hover:bg-slate-950 active:scale-[0.96] focus:grid focus:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 data-[state=open]:grid data-[state=open]:opacity-100 lg:grid"
onKeyDown={handleOperationKeyDown}
onPointerDown={(event) => event.stopPropagation()}
>
<Grip size={18} aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-window-operation
side="bottom"
align="start"
sideOffset={6}
className="w-52 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.2)] motion-reduce:animate-none"
>
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMinimize}>
<Minus size={16} aria-hidden="true" />
</DropdownMenuItem>
{mode === "maximized" || mode === "docked" ? (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onRestore}>
<Minimize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
) : (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMaximize}>
<Maximize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
)}
{onClose ? (
<>
<DropdownMenuSeparator className="my-1 bg-slate-200" />
<DropdownMenuItem
className="min-h-10 rounded-md text-xs font-semibold text-rose-700 focus:bg-rose-50 focus:text-rose-800"
onSelect={onClose}
>
<X size={16} aria-hidden="true" />
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{swapTarget ? ( {swapTarget ? (
<div <div
aria-hidden="true" aria-hidden="true"
@@ -244,23 +287,6 @@ export function WorkspaceWindow({
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
function WindowButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
className={cn(
"grid h-10 w-10 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",
danger ? "hover:bg-rose-100 hover:text-rose-700" : "hover:bg-white hover:text-blue-700"
)}
onClick={(event) => { event.stopPropagation(); onClick(); }}
>
{children}
</button>
);
}
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect { function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY }; if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
const west = direction.includes("w"); const west = direction.includes("w");
+37 -10
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
test("temporary Agent analysis windows replace and restore the map workspace", async ({ page }) => { test("temporary Agent analysis windows float above the persistent map workspace", async ({ page }) => {
await prepareMainFrame(page); await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -12,22 +12,29 @@ test("temporary Agent analysis windows replace and restore the map workspace", a
await expect(frame).toBeVisible(); await expect(frame).toBeVisible();
await expect(mapWindow).toBeVisible(); await expect(mapWindow).toBeVisible();
await expect(testButton).toBeVisible(); await expect(testButton).toBeVisible();
await expect(page.getByLabel("工作台导航")).toHaveCount(0);
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await expect(mapWindow.getByRole("navigation", { name: "地图工具" })).toBeVisible();
await expect(page.locator(".workspace-window-titlebar")).toHaveCount(0);
await testButton.click(); await testButton.click();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible();
await expect(mapWindow).toBeHidden(); await expect(mapWindow).toBeVisible();
await testButton.click(); await testButton.click();
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible(); await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible();
await page.getByRole("button", { name: "最小化供水服务分区平衡分析" }).click(); await openWindowOperationMenu(page, "供水服务分区平衡分析");
await page.getByRole("menuitem", { name: "最小化" }).click();
await expect(page.getByRole("button", { name: "恢复供水服务分区平衡分析" })).toBeVisible(); await expect(page.getByRole("button", { name: "恢复供水服务分区平衡分析" })).toBeVisible();
await page.getByRole("button", { name: "恢复供水服务分区平衡分析" }).click(); await page.getByRole("button", { name: "恢复供水服务分区平衡分析" }).click();
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
await page.getByRole("button", { name: "关闭并销毁供水服务分区平衡分析" }).click(); await openWindowOperationMenu(page, "供水服务分区平衡分析");
await page.getByRole("button", { name: "关闭并销毁北辰分区压力异常诊断" }).click(); await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
await openWindowOperationMenu(page, "北辰分区压力异常诊断");
await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
await expect(mapWindow).toBeVisible(); await expect(mapWindow).toBeVisible();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0);
await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible(); await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible();
@@ -42,21 +49,27 @@ test("the map document floats and minimizes to its taskbar entry", async ({ page
const dockedBox = await workspace.boundingBox(); const dockedBox = await workspace.boundingBox();
expect(dockedBox).not.toBeNull(); expect(dockedBox).not.toBeNull();
await page.getByRole("button", { name: "浮动供水管网地图" }).click(); await openWindowOperationMenu(page, "供水管网地图");
await page.getByRole("menuitem", { name: "恢复为浮动窗口" }).click();
const floatingBox = await mapWindow.boundingBox(); const floatingBox = await mapWindow.boundingBox();
expect(floatingBox).not.toBeNull(); expect(floatingBox).not.toBeNull();
expect(floatingBox!.width).toBeLessThan(dockedBox!.width); expect(floatingBox!.width).toBeLessThan(dockedBox!.width);
expect(floatingBox!.height).toBeLessThan(dockedBox!.height); expect(floatingBox!.height).toBeLessThan(dockedBox!.height);
await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible(); await expect(mapWindow).toHaveCSS("border-radius", "0px");
const floatingClassName = await mapWindow.getAttribute("class");
expect(floatingClassName).not.toContain("ring-1");
expect(await mapWindow.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none");
await expect(page.getByRole("button", { name: "打开供水管网地图窗口操作" })).toBeAttached();
await page.getByRole("button", { name: "最小化供水管网地图" }).click(); await openWindowOperationMenu(page, "供水管网地图");
await page.getByRole("menuitem", { name: "最小化" }).click();
await expect(mapWindow).toBeHidden(); await expect(mapWindow).toBeHidden();
const restoreTask = page.getByRole("button", { name: "恢复供水管网地图" }); const restoreTask = page.getByRole("button", { name: "恢复供水管网地图" });
await expect(restoreTask).toBeVisible(); await expect(restoreTask).toBeVisible();
await restoreTask.click(); await restoreTask.click();
await expect(mapWindow).toBeVisible(); await expect(mapWindow).toBeVisible();
await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible(); await expect(page.getByRole("button", { name: "打开供水管网地图窗口操作" })).toBeAttached();
}); });
test("the test control cycles through three trusted document profiles", async ({ page }) => { test("the test control cycles through three trusted document profiles", async ({ page }) => {
@@ -89,7 +102,7 @@ test("temporary documents survive a mobile breakpoint round trip", async ({ page
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await expect(analysisHeading).toBeVisible(); await expect(analysisHeading).toBeVisible();
await expect(page.locator("#workspace-map")).toBeHidden(); await expect(page.locator("#workspace-map")).toBeVisible();
}); });
test("layout presets arrange transient windows and keep the map visible", async ({ page }) => { test("layout presets arrange transient windows and keep the map visible", async ({ page }) => {
@@ -108,6 +121,7 @@ test("layout presets arrange transient windows and keep the map visible", async
await expect(page.locator("#workspace-map")).toBeVisible(); await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toBeVisible(); await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toBeVisible();
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']")).toBeHidden(); await expect(page.locator("section[aria-label='北辰分区压力异常诊断']")).toBeHidden();
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toHaveCSS("border-radius", "0px");
const workspaceBox = await workspace.boundingBox(); const workspaceBox = await workspace.boundingBox();
const primaryBox = await page.locator("section[aria-label='低压事件调度处置流程']").boundingBox(); const primaryBox = await page.locator("section[aria-label='低压事件调度处置流程']").boundingBox();
@@ -138,6 +152,7 @@ test("dragging a small window onto the primary window swaps their complete slots
expect(mapBefore!.width).toBeLessThan(analysisBefore!.width); expect(mapBefore!.width).toBeLessThan(analysisBefore!.width);
await page.mouse.move(mapBefore!.x + 72, mapBefore!.y + 22); await page.mouse.move(mapBefore!.x + 72, mapBefore!.y + 22);
await page.keyboard.down("Alt");
await page.mouse.down(); await page.mouse.down();
await page.mouse.move( await page.mouse.move(
analysisBefore!.x + analysisBefore!.width / 2, analysisBefore!.x + analysisBefore!.width / 2,
@@ -146,6 +161,7 @@ test("dragging a small window onto the primary window swaps their complete slots
); );
await expect(page.getByText("释放以交换")).toBeVisible(); await expect(page.getByText("释放以交换")).toBeVisible();
await page.mouse.up(); await page.mouse.up();
await page.keyboard.up("Alt");
const mapAfter = await mapWindow.boundingBox(); const mapAfter = await mapWindow.boundingBox();
const analysisAfter = await analysisWindow.boundingBox(); const analysisAfter = await analysisWindow.boundingBox();
@@ -172,21 +188,32 @@ test("dropping on blank workspace exits auto layout and Escape cancels a drag",
expect(workspaceBox).not.toBeNull(); expect(workspaceBox).not.toBeNull();
await page.mouse.move(before!.x + 80, before!.y + 22); await page.mouse.move(before!.x + 80, before!.y + 22);
await page.keyboard.down("Alt");
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 }); await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.keyboard.up("Alt");
await expect(workspace).toHaveAttribute("data-layout-mode", "split"); await expect(workspace).toHaveAttribute("data-layout-mode", "split");
const afterCancel = await analysisWindow.boundingBox(); const afterCancel = await analysisWindow.boundingBox();
expect(Math.abs(afterCancel!.x - before!.x)).toBeLessThanOrEqual(2); expect(Math.abs(afterCancel!.x - before!.x)).toBeLessThanOrEqual(2);
await page.mouse.move(before!.x + 80, before!.y + 22); await page.mouse.move(before!.x + 80, before!.y + 22);
await page.keyboard.down("Alt");
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 }); await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.mouse.up(); await page.mouse.up();
await page.keyboard.up("Alt");
await expect(workspace).toHaveAttribute("data-layout-mode", "free"); await expect(workspace).toHaveAttribute("data-layout-mode", "free");
await expect(page.getByText("北辰分区压力异常诊断已转为自由排列")).toBeAttached(); await expect(page.getByText("北辰分区压力异常诊断已转为自由排列")).toBeAttached();
}); });
async function openWindowOperationMenu(page: Page, title: string) {
const window = page.locator(`section[aria-label='${title}']`);
await page.getByRole("button", { name: `切换到${title}` }).click();
await window.hover();
await page.getByRole("button", { name: `打开${title}窗口操作` }).click();
}
async function prepareMainFrame(page: Page) { async function prepareMainFrame(page: Page) {
await mockAgentApi(page); await mockAgentApi(page);
await page.route("**/runtime-config.js", async (route) => { await page.route("**/runtime-config.js", async (route) => {