diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 54d13b2..7ba3ec3 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) = expect(hoverBackground).not.toBe(restingBackground); }); -test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => { +test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); @@ -112,10 +112,17 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as expect(menuTransformOrigin).not.toBe("50% 50%"); await page.keyboard.press("Escape"); - const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first(); - const commandShadow = await agentCommandControl.evaluate( - (element) => window.getComputedStyle(element).boxShadow + const navigation = page.getByRole("complementary", { name: "工作台导航" }); + const navigationMaterial = await navigation.evaluate( + (element) => { + const style = window.getComputedStyle(element); + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor + }; + } ); - expect(commandShadow).toBe("none"); - await expect(agentCommandControl).toHaveCSS("border-top-width", "0px"); + expect(navigationMaterial.backdropFilter).toBe("none"); + expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)"); + await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toBeVisible(); }); diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index 8ab0113..e410630 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -98,6 +98,8 @@ export function ScheduledConditionFeed({ const [statusFilter, setStatusFilter] = useState(CONDITION_FILTER_ALL); const mobileSheet = presentation === "mobile-sheet"; + const desktopDock = presentation === "desktop-dock"; + const compactPresentation = mobileSheet || desktopDock; const desktopFloating = presentation === "desktop-floating"; const expanded = controlledExpanded ?? uncontrolledExpanded; const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId; @@ -298,7 +300,7 @@ export function ScheduledConditionFeed({ : "max-h-[calc(100dvh-8rem)]" ) : "h-full rounded-l-2xl rounded-r-none", - expanded + expanded && !desktopDock ? "scheduled-feed-panel-expanded flex flex-col" : "w-[var(--workbench-condition-width)]" ) @@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
map.resize(); + const resizeObserver = new ResizeObserver(() => { + if (resizeFrame !== null) { + window.cancelAnimationFrame(resizeFrame); + } + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = null; + resizeMap(); + }); + }); + resizeObserver.observe(containerRef.current); window.addEventListener("resize", resizeMap); map.on("sourcedata", (event) => { @@ -198,6 +209,10 @@ export function useWorkbenchMap({ return () => { window.removeEventListener("resize", resizeMap); + if (resizeFrame !== null) { + window.cancelAnimationFrame(resizeFrame); + } + resizeObserver.disconnect(); map.remove(); mapRef.current = null; delete window.__waterNetworkMap; diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 32bee57..f24f6f2 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -35,7 +35,6 @@ import { MapDevPanel } from "./components/map-dev-panel"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; -import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { ToolbarPanel, type ExportViewPreset, @@ -43,6 +42,10 @@ import { type ToolbarToolId } from "./components/toolbar-panel"; import { WorkbenchTopBar } from "./components/workbench-top-bar"; +import { + WorkbenchMainFrame, + type WorkbenchNavigationSection +} from "./workspace/workbench-main-frame"; import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; @@ -101,6 +104,8 @@ export function MapWorkbenchPage({ const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const mapContainerRef = useRef(null); const [detailFeature, setDetailFeature] = useState(null); + const [mainFrameSection, setMainFrameSection] = useState("agent"); + const [mainFrameNavigationCollapsed, setMainFrameNavigationCollapsed] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false); const [impactVisible, setImpactVisible] = useState(false); const [activeToolId, setActiveToolId] = useState(null); @@ -149,7 +154,6 @@ export function MapWorkbenchPage({ agentPanelWidth, closeMobileSheet, conditionFeedExpanded, - conditionFeedMounted, handleConditionExpandedChange, isLargeScreen, leftPanelOpen, @@ -159,11 +163,8 @@ export function MapWorkbenchPage({ openConditionFeedForViewport, rightPanelExpanded, rightPanelOpen, - setAgentPanelWidth, setMobileSheetSnap, - shouldShowConditionFeed, - toggleConditionFeedForViewport, - viewportWidth + toggleConditionFeedForViewport } = useWorkbenchResponsiveLayout({ activeToolOpen: activeToolId !== null, agentPanelOpen: agent.panelOpen, @@ -173,6 +174,31 @@ export function MapWorkbenchPage({ onClearActiveTool: clearActiveTool }); + function openAgentWorkspaceForViewport() { + if (isLargeScreen) { + setMainFrameSection("agent"); + setMainFrameNavigationCollapsed(false); + } + openAgentPanelForViewport(); + } + + function openConditionWorkspaceForViewport() { + if (isLargeScreen) { + setMainFrameSection("conditions"); + setMainFrameNavigationCollapsed(false); + } + openConditionFeedForViewport(); + } + + function toggleConditionWorkspaceForViewport() { + if (isLargeScreen) { + setMainFrameSection((current) => current === "conditions" ? "agent" : "conditions"); + setMainFrameNavigationCollapsed(false); + return; + } + toggleConditionFeedForViewport(); + } + const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ containerRef: mapContainerRef, impactVisible, @@ -182,9 +208,9 @@ export function MapWorkbenchPage({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ mapRef, mapReady, - leftPanelOpen, - rightPanelOpen, - conditionPanelExpanded: rightPanelExpanded, + leftPanelOpen: isLargeScreen ? false : leftPanelOpen, + rightPanelOpen: isLargeScreen ? devPanelOpen : rightPanelOpen, + conditionPanelExpanded: isLargeScreen ? false : rightPanelExpanded, agentPanelWidth }); @@ -442,7 +468,7 @@ export function MapWorkbenchPage({ return; } - openAgentPanelForViewport(); + openAgentWorkspaceForViewport(); try { await agent.submitPrompt( @@ -479,7 +505,7 @@ export function MapWorkbenchPage({ return; } - openConditionFeedForViewport(); + openConditionWorkspaceForViewport(); setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 @@ -601,9 +627,9 @@ export function MapWorkbenchPage({ zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14), padding: getResponsiveWorkbenchPadding( map, - leftPanelOpen, - rightPanelOpen, - rightPanelExpanded, + isLargeScreen ? false : leftPanelOpen, + isLargeScreen ? devPanelOpen : rightPanelOpen, + isLargeScreen ? false : rightPanelExpanded, agentPanelWidth ), duration: trustedAction.durationMs ?? 500 @@ -764,13 +790,6 @@ export function MapWorkbenchPage({ } as CSSProperties } > -
- setTaskTickerVisible((current) => !current)} onToggleDevPanel={() => setDevPanelOpen((current) => !current)} onPreviewScenario={handlePreviewScenario} @@ -799,50 +818,73 @@ export function MapWorkbenchPage({ onLogout={onLogout} /> - - - {!devPanelOpen ? ( -
- -
- ) : null} - - {conditionFeedMounted && !devPanelOpen ? ( -
+ ( + + )} + conditionsPanel={( -
- ) : null} - - {!devPanelOpen ? ( -
- -
- ) : null} + )} + layersPanel={( +
+ + +
+ )} + toolsPanel={( +
+ + +
+ )} + mapContent={( +
+ )} + mapOverlay={( +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ )} + /> {devPanelEnabled && devPanelOpen ? ( ) : null} -
-
- -
- -
- {shouldShowTaskTicker ? ( AnalysisDocument; + +const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({ + id, + title: "北辰分区压力异常诊断", + subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况", + generatedAt, + blocks: [ + { + id: "pressure-metrics", + kind: "metric-grid", + title: "诊断摘要", + span: 12, + metrics: [ + { label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" }, + { label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" }, + { label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" }, + { label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" } + ] + }, + { + id: "pressure-trend", + kind: "chart", + title: "关键节点压力曲线", + description: "BC-P-014 在 09:40 后持续低于调度下限", + span: 7, + chartType: "line", + categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"], + unit: "MPa", + series: [ + { name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" }, + { name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" }, + { name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" } + ] + }, + { + id: "pressure-ranking", + kind: "chart", + title: "异常偏差排名", + description: "按相对调度下限的压力偏差排序", + span: 5, + chartType: "bar", + categories: ["P-014", "P-008", "P-026", "P-031", "P-017"], + unit: "%", + series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }] + }, + { + id: "pressure-evidence", + kind: "data-table", + title: "证据链", + description: "对诊断结论贡献最高的观测证据", + span: 12, + columns: [ + { key: "time", label: "时间" }, + { key: "source", label: "数据源" }, + { key: "finding", label: "观测" }, + { key: "impact", label: "影响" }, + { key: "confidence", label: "置信度", numeric: true } + ], + rows: [ + { time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" }, + { time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" }, + { time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" } + ] + } + ] +}); + +const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({ + id, + title: "供水服务分区平衡分析", + subtitle: "Agent 对比分区供水量、需求预测和关键连通通道", + generatedAt, + blocks: [ + { + id: "zone-metrics", + kind: "metric-grid", + title: "分区态势", + span: 12, + metrics: [ + { label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" }, + { label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" }, + { label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" }, + { label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" } + ] + }, + { + id: "zone-balance", + kind: "chart", + title: "分区供需对比", + description: "北辰与东丽当前需求已接近可用供水能力", + span: 7, + chartType: "bar", + categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"], + unit: "万m³/d", + series: [ + { name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" }, + { name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" } + ] + }, + { + id: "zone-risk", + kind: "data-table", + title: "调配优先级", + description: "按负荷率、连续供水和通道可用性综合排序", + span: 5, + columns: [ + { key: "zone", label: "分区" }, + { key: "load", label: "负荷率", numeric: true }, + { key: "risk", label: "风险" }, + { key: "action", label: "建议" } + ], + rows: [ + { zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" }, + { zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" }, + { zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" }, + { zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" } + ] + }, + { + id: "zone-note", + kind: "narrative", + title: "Agent 结论", + span: 12, + paragraphs: [ + "优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。", + "执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。" + ] + } + ] +}); + +const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({ + id, + title: "低压事件调度处置流程", + subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程", + generatedAt, + blocks: [ + { + id: "flow-metrics", + kind: "metric-grid", + title: "执行状态", + span: 12, + metrics: [ + { label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" }, + { label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" }, + { label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" }, + { label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" } + ] + }, + { + id: "dispatch-process", + kind: "process-flow", + title: "处置路径", + description: "所有调度动作均需在执行前完成权限和现场状态确认", + span: 7, + nodes: [ + { id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" }, + { id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" }, + { id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" }, + { id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" }, + { id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" } + ] + }, + { + id: "dispatch-checklist", + kind: "data-table", + title: "执行清单", + description: "当前阶段需要完成的人员与系统确认", + span: 5, + columns: [ + { key: "owner", label: "责任方" }, + { key: "item", label: "确认项" }, + { key: "state", label: "状态" } + ], + rows: [ + { owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" }, + { owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" }, + { owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" }, + { owner: "系统管理员", item: "确认远控操作权限", state: "待处理" } + ] + }, + { + id: "dispatch-progress", + kind: "chart", + title: "处置前后压力预测", + description: "模拟结果显示执行后 20 分钟内压力回到调度区间", + span: 12, + chartType: "line", + categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"], + unit: "MPa", + series: [ + { name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" }, + { name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" } + ] + } + ] +}); + +const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const; + +export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument { + const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS; + const now = new Date(); + return factory( + `analysis-${now.getTime().toString(36)}-${instance}`, + now.toLocaleTimeString("zh-CN", { hour12: false }) + ); +} diff --git a/src/features/workbench/workspace/analysis-document-view.tsx b/src/features/workbench/workspace/analysis-document-view.tsx new file mode 100644 index 0000000..72cb0bd --- /dev/null +++ b/src/features/workbench/workspace/analysis-document-view.tsx @@ -0,0 +1,203 @@ +import ReactECharts from "echarts-for-react"; +import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react"; +import { cn } from "@/shared/ui/cn"; +import type { + AnalysisBlock, + AnalysisDocument, + AnalysisMetric +} from "./workspace-model"; + +export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) { + return ( +
+
+
+
+
+

{document.title}

+

{document.subtitle}

+
+
+
+
+ +
+ {document.blocks.map((block) => ( + + ))} +
+
+ ); +} + +function AnalysisBlockView({ block }: { block: AnalysisBlock }) { + const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12"; + + if (block.kind === "metric-grid") { + return ( +
+

{block.title}

+
+ {block.metrics.map((metric) => ( + + ))} +
+
+ ); + } + + if (block.kind === "chart") { + return ( + +
+ +
+
+ ); + } + + if (block.kind === "process-flow") { + return ( + +
    + {block.nodes.map((node, index) => ( +
  1. + {index < block.nodes.length - 1 ? ( +
  2. + ))} +
+
+ ); + } + + if (block.kind === "data-table") { + return ( + +
+ + + + {block.columns.map((column) => ( + + ))} + + + + {block.rows.map((row, index) => ( + + {block.columns.map((column) => ( + + ))} + + ))} + +
{column.label}
{row[column.key]}
+
+
+ ); + } + + return ( + +
+ {block.paragraphs.map((paragraph) =>

{paragraph}

)} +
+
+ ); +} + +function AnalysisSection({ + block, + className, + children +}: { + block: Exclude; + className: string; + children: React.ReactNode; +}) { + return ( +
+
+ +
+

{block.title}

+ {"description" in block ?

{block.description}

: null} +
+
+
{children}
+
+ ); +} + +function MetricTile({ metric }: { metric: AnalysisMetric }) { + return ( +
+
+
+

{metric.value}

+

{metric.detail}

+
+ ); +} + +function createChartOption(block: Extract) { + return { + animationDuration: 260, + animationEasing: "cubicOut", + color: block.series.map((series) => series.color), + grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false }, + legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } }, + tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } }, + xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } }, + yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } }, + series: block.series.map((series) => ({ + name: series.name, + type: series.type ?? block.chartType, + data: series.values, + smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined, + symbol: "circle", + symbolSize: 6, + barMaxWidth: 24, + lineStyle: { width: 2.5 }, + itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined } + })) + }; +} + +function metricToneClass(tone: AnalysisMetric["tone"]) { + if (tone === "danger") return "bg-rose-500"; + if (tone === "warning") return "bg-amber-500"; + if (tone === "success") return "bg-emerald-500"; + if (tone === "info") return "bg-blue-600"; + return "bg-slate-400"; +} diff --git a/src/features/workbench/workspace/workbench-main-frame.tsx b/src/features/workbench/workspace/workbench-main-frame.tsx new file mode 100644 index 0000000..754243b --- /dev/null +++ b/src/features/workbench/workspace/workbench-main-frame.tsx @@ -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(null); + const [bounds, setBounds] = useState(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 ( +
+ + +
+ 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} + + + {workspace.analyses.map((window) => ( + 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))} + > + + + ))} +
+ +
+
+ {!desktop ? 移动端保持地图与抽屉工作台 : null} +
+ ); +} + +function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) { + return ( + + ); +} + +function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) { + return ( + + ); +} diff --git a/src/features/workbench/workspace/workspace-model.test.ts b/src/features/workbench/workspace/workspace-model.test.ts new file mode 100644 index 0000000..d6d73cb --- /dev/null +++ b/src/features/workbench/workspace/workspace-model.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_ANALYSIS_WINDOWS, + closeAnalysisWindow, + createInitialWorkspaceState, + maximizeWorkspaceWindow, + minimizeWorkspaceWindow, + openAnalysisDocument, + resizeWorkspace, + restoreWorkspaceWindow, + updateWorkspaceWindowRect, + type AnalysisDocument +} from "./workspace-model"; + +const bounds = { width: 1180, height: 760 }; + +function documentAt(index: number): AnalysisDocument { + return { + id: `analysis-${index}`, + title: `分析 ${index}`, + subtitle: "测试", + generatedAt: "10:40:00", + blocks: [] + }; +} + +describe("workspace window lifecycle", () => { + it("minimizes the map for the first analysis and restores it after the last window closes", () => { + const initial = createInitialWorkspaceState(bounds); + const opened = openAnalysisDocument(initial, documentAt(1), bounds); + + expect(opened.map.mode).toBe("minimized"); + expect(opened.analyses).toHaveLength(1); + + const closed = closeAnalysisWindow(opened, "analysis-1"); + expect(closed.analyses).toHaveLength(0); + expect(closed.map.mode).toBe("docked"); + expect(closed.activeWindowId).toBe("workspace-map"); + }); + + it("supports minimize, maximize and bounded restore", () => { + const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds); + const minimized = minimizeWorkspaceWindow(opened, "analysis-1"); + expect(minimized.analyses[0]?.mode).toBe("minimized"); + + const restored = restoreWorkspaceWindow(minimized, "analysis-1", bounds); + expect(restored.analyses[0]?.mode).toBe("floating"); + + const maximized = maximizeWorkspaceWindow(restored, "analysis-1"); + expect(maximized.analyses[0]?.mode).toBe("maximized"); + + const resized = updateWorkspaceWindowRect( + maximized, + "analysis-1", + { x: -100, y: -80, width: 4000, height: 3000 }, + bounds + ); + expect(resized.analyses[0]?.rect).toEqual({ x: 12, y: 12, width: 1156, height: 736 }); + }); + + it("caps transient analysis windows without deleting an existing result", () => { + const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce( + (current, index) => openAnalysisDocument(current, documentAt(index), bounds), + createInitialWorkspaceState(bounds) + ); + + const overflow = openAnalysisDocument(state, documentAt(99), bounds); + expect(overflow).toBe(state); + expect(overflow.analyses).toHaveLength(MAX_ANALYSIS_WINDOWS); + }); + + it("reclamps current and restore rectangles when the workspace shrinks", () => { + const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds); + const moved = updateWorkspaceWindowRect( + opened, + "analysis-1", + { x: 500, y: 260, width: 660, height: 480 }, + bounds + ); + + const resized = resizeWorkspace(moved, { width: 800, height: 600 }); + const analysis = resized.analyses[0]; + expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 }); + expect(analysis?.restoreRect).toEqual(analysis?.rect); + expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788); + expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588); + }); +}); diff --git a/src/features/workbench/workspace/workspace-model.ts b/src/features/workbench/workspace/workspace-model.ts new file mode 100644 index 0000000..75894b7 --- /dev/null +++ b/src/features/workbench/workspace/workspace-model.ts @@ -0,0 +1,360 @@ +export const MAX_ANALYSIS_WINDOWS = 6; + +export type WorkspaceRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WorkspaceBounds = { + width: number; + height: number; +}; + +export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized"; + +export type AnalysisMetric = { + label: string; + value: string; + detail: string; + tone: "neutral" | "info" | "success" | "warning" | "danger"; +}; + +export type AnalysisChartSeries = { + name: string; + values: number[]; + color: string; + type?: "line" | "bar"; +}; + +export type AnalysisBlock = + | { + id: string; + kind: "metric-grid"; + title: string; + span: 12; + metrics: AnalysisMetric[]; + } + | { + id: string; + kind: "chart"; + title: string; + description: string; + span: 5 | 7 | 12; + chartType: "line" | "bar"; + categories: string[]; + unit: string; + series: AnalysisChartSeries[]; + } + | { + id: string; + kind: "process-flow"; + title: string; + description: string; + span: 5 | 7 | 12; + nodes: Array<{ + id: string; + label: string; + detail: string; + status: "complete" | "active" | "pending"; + }>; + } + | { + id: string; + kind: "data-table"; + title: string; + description: string; + span: 5 | 7 | 12; + columns: Array<{ key: string; label: string; numeric?: boolean }>; + rows: Array>; + } + | { + id: string; + kind: "narrative"; + title: string; + span: 5 | 7 | 12; + paragraphs: string[]; + }; + +export type AnalysisDocument = { + id: string; + title: string; + subtitle: string; + generatedAt: string; + blocks: AnalysisBlock[]; +}; + +export type AnalysisWorkspaceWindow = { + id: string; + kind: "agent-analysis"; + title: string; + mode: Exclude; + rect: WorkspaceRect; + restoreRect: WorkspaceRect; + zIndex: number; + document: AnalysisDocument; +}; + +export type MapWorkspaceWindow = { + id: "workspace-map"; + kind: "map"; + title: string; + mode: WorkspaceWindowMode; + rect: WorkspaceRect; + restoreRect: WorkspaceRect; + zIndex: number; +}; + +export type WorkspaceState = { + map: MapWorkspaceWindow; + analyses: AnalysisWorkspaceWindow[]; + activeWindowId: string; + nextZIndex: number; +}; + +const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 }; + +export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState { + const mapRect = getDefaultMapRect(bounds); + return { + map: { + id: "workspace-map", + kind: "map", + title: "供水管网地图", + mode: "docked", + rect: mapRect, + restoreRect: mapRect, + zIndex: 1 + }, + analyses: [], + activeWindowId: "workspace-map", + nextZIndex: 2 + }; +} + +export function openAnalysisDocument( + state: WorkspaceState, + document: AnalysisDocument, + bounds: WorkspaceBounds +): WorkspaceState { + if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) { + return state; + } + + const rect = getDefaultAnalysisRect(bounds, state.analyses.length); + const window: AnalysisWorkspaceWindow = { + id: document.id, + kind: "agent-analysis", + title: document.title, + mode: "floating", + rect, + restoreRect: rect, + zIndex: state.nextZIndex, + document + }; + + return { + map: { ...state.map, mode: "minimized" }, + analyses: [...state.analyses, window], + activeWindowId: window.id, + nextZIndex: state.nextZIndex + 1 + }; +} + +export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState { + const analyses = state.analyses.filter((window) => window.id !== windowId); + if (analyses.length === state.analyses.length) { + return state; + } + + if (analyses.length === 0) { + return { + ...state, + map: { ...state.map, mode: "docked", zIndex: state.nextZIndex }, + analyses, + activeWindowId: state.map.id, + nextZIndex: state.nextZIndex + 1 + }; + } + + const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0]; + return { + ...state, + analyses, + activeWindowId: nextActive?.id ?? state.map.id + }; +} + +export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { + if (windowId === state.map.id) { + return { + ...state, + map: { ...state.map, zIndex: state.nextZIndex }, + activeWindowId: windowId, + nextZIndex: state.nextZIndex + 1 + }; + } + + if (!state.analyses.some((window) => window.id === windowId)) { + return state; + } + + return { + ...state, + analyses: state.analyses.map((window) => + window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window + ), + activeWindowId: windowId, + nextZIndex: state.nextZIndex + 1 + }; +} + +export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { + if (windowId === state.map.id) { + return { ...state, map: { ...state.map, mode: "minimized" } }; + } + + return { + ...state, + analyses: state.analyses.map((window) => + window.id === windowId ? { ...window, mode: "minimized" } : window + ) + }; +} + +export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { + if (windowId === state.map.id) { + return focusWorkspaceWindow( + { ...state, map: { ...state.map, mode: "maximized" } }, + windowId + ); + } + + return focusWorkspaceWindow( + { + ...state, + analyses: state.analyses.map((window) => + window.id === windowId ? { ...window, mode: "maximized" } : window + ) + }, + windowId + ); +} + +export function restoreWorkspaceWindow( + state: WorkspaceState, + windowId: string, + bounds: WorkspaceBounds +): WorkspaceState { + if (windowId === state.map.id) { + const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map"); + return focusWorkspaceWindow( + { ...state, map: { ...state.map, mode: "floating", rect } }, + windowId + ); + } + + return focusWorkspaceWindow( + { + ...state, + analyses: state.analyses.map((window) => + window.id === windowId + ? { + ...window, + mode: "floating", + rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis") + } + : window + ) + }, + windowId + ); +} + +export function updateWorkspaceWindowRect( + state: WorkspaceState, + windowId: string, + rect: WorkspaceRect, + bounds: WorkspaceBounds +): WorkspaceState { + if (windowId === state.map.id) { + const nextRect = clampWorkspaceRect(rect, bounds, "map"); + return { + ...state, + map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect } + }; + } + + return { + ...state, + analyses: state.analyses.map((window) => { + if (window.id !== windowId) return window; + const nextRect = clampWorkspaceRect(rect, bounds, "analysis"); + return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect }; + }) + }; +} + +export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState { + return { + ...state, + map: { + ...state.map, + rect: clampWorkspaceRect(state.map.rect, bounds, "map"), + restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map") + }, + analyses: state.analyses.map((window) => ({ + ...window, + rect: clampWorkspaceRect(window.rect, bounds, "analysis"), + restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis") + })) + }; +} + +export function clampWorkspaceRect( + rect: WorkspaceRect, + bounds: WorkspaceBounds, + kind: "map" | "analysis" +): WorkspaceRect { + const minimumWidth = kind === "map" ? 360 : 640; + const minimumHeight = kind === "map" ? 280 : 420; + const availableWidth = Math.max(280, bounds.width - 24); + const availableHeight = Math.max(240, bounds.height - 24); + const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth); + const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight); + return { + x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)), + y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)), + width, + height + }; +} + +function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect { + const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48)); + const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48)); + const cascade = (index % 5) * 28; + return clampWorkspaceRect( + { x: 24 + cascade, y: 20 + cascade, width, height }, + bounds, + "analysis" + ); +} + +function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect { + return clampWorkspaceRect( + { + x: Math.max(12, bounds.width * 0.36), + y: Math.max(12, bounds.height * 0.18), + width: bounds.width * 0.6, + height: bounds.height * 0.72 + }, + bounds, + "map" + ); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/src/features/workbench/workspace/workspace-window.tsx b/src/features/workbench/workspace/workspace-window.tsx new file mode 100644 index 0000000..3c10ad3 --- /dev/null +++ b/src/features/workbench/workspace/workspace-window.tsx @@ -0,0 +1,195 @@ +import { + Copy, + Maximize2, + Minimize2, + Minus, + PanelTopClose, + X +} from "lucide-react"; +import { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { cn } from "@/shared/ui/cn"; +import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model"; + +type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; + +type WorkspaceWindowProps = { + id: string; + title: string; + mode: WorkspaceWindowMode; + rect: WorkspaceRect; + zIndex: number; + active: boolean; + kind: "map" | "analysis"; + children: ReactNode; + onFocus: () => void; + onRectChange: (rect: WorkspaceRect) => void; + onMinimize: () => void; + onMaximize: () => void; + onRestore: () => void; + onClose?: () => void; +}; + +export function WorkspaceWindow({ + id, + title, + mode, + rect, + zIndex, + active, + kind, + children, + onFocus, + onRectChange, + onMinimize, + onMaximize, + onRestore, + onClose +}: WorkspaceWindowProps) { + const rectRef = useRef(rect); + rectRef.current = rect; + + const floating = mode === "floating"; + const style = mode === "docked" + ? { inset: 0, zIndex } + : mode === "maximized" + ? { inset: 8, zIndex } + : { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex }; + + const beginPointerAction = ( + event: ReactPointerEvent, + action: "move" | ResizeDirection + ) => { + if (!floating || event.button !== 0) return; + if (event.target instanceof Element && event.target.closest("button")) return; + event.preventDefault(); + onFocus(); + const startX = event.clientX; + const startY = event.clientY; + const startRect = rectRef.current; + + const handleMove = (pointerEvent: PointerEvent) => { + const deltaX = pointerEvent.clientX - startX; + const deltaY = pointerEvent.clientY - startY; + onRectChange(resizeRect(startRect, action, deltaX, deltaY)); + }; + const handleUp = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp, { once: true }); + }; + + const handleTitleKeyDown = (event: React.KeyboardEvent) => { + if (!floating || !event.key.startsWith("Arrow")) return; + event.preventDefault(); + const step = event.altKey ? 2 : 12; + const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0; + const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0; + onRectChange( + event.shiftKey + ? { ...rect, width: rect.width + deltaX, height: rect.height + deltaY } + : { ...rect, x: rect.x + deltaX, y: rect.y + deltaY } + ); + }; + + return ( +
+ +
{children}
+ {floating ? RESIZE_DIRECTIONS.map((direction) => ( +
+ ); +} + +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 ( + + ); +} + +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 }; + const west = direction.includes("w"); + const east = direction.includes("e"); + const north = direction.includes("n"); + const south = direction.includes("s"); + return { + x: west ? rect.x + deltaX : rect.x, + y: north ? rect.y + deltaY : rect.y, + width: rect.width + (east ? deltaX : west ? -deltaX : 0), + height: rect.height + (south ? deltaY : north ? -deltaY : 0) + }; +} + +function resizeHandleClass(direction: ResizeDirection) { + const shared = "absolute z-20 block touch-none"; + const classes: Record = { + n: "left-2 right-2 top-0 h-2 cursor-n-resize", + ne: "right-0 top-0 h-3 w-3 cursor-ne-resize", + e: "bottom-2 right-0 top-2 w-2 cursor-e-resize", + se: "bottom-0 right-0 h-3 w-3 cursor-se-resize", + s: "bottom-0 left-2 right-2 h-2 cursor-s-resize", + sw: "bottom-0 left-0 h-3 w-3 cursor-sw-resize", + w: "bottom-2 left-0 top-2 w-2 cursor-w-resize", + nw: "left-0 top-0 h-3 w-3 cursor-nw-resize" + }; + return `${shared} ${classes[direction]}`; +} diff --git a/src/styles.css b/src/styles.css index bca6f6b..1023e6a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -899,6 +899,49 @@ button.agent-history-session-trigger:not(:disabled):active { background: var(--surface-reading); } +/* Desktop Main Frame uses solid, work-focused panes instead of map-overlay acrylic. */ +.workbench-main-frame .agent-panel-shell, +.workbench-main-frame .scheduled-feed-panel-shell, +.workbench-main-frame .surface-dock, +.workbench-main-frame .surface-control, +.workbench-main-frame .surface-reading { + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.workbench-main-frame .agent-panel-shell { + border: 0; + border-radius: 0; + background: #f7f9fb; + box-shadow: none; +} + +.workbench-main-frame .scheduled-feed-panel-shell { + width: 100%; + max-width: none; + height: 100%; + border: 0; + border-radius: 0; + background: #f7f9fb; + box-shadow: none; +} + +.workbench-main-frame .workspace-window-titlebar { + -webkit-user-select: none; + user-select: none; +} + +@media (forced-colors: active) { + .workbench-main-frame [aria-label="工作台导航"], + .workbench-main-frame [aria-label="工作区窗口任务栏"], + .workbench-main-frame .workspace-window-titlebar { + border-color: CanvasText; + background: Canvas; + color: CanvasText; + box-shadow: none; + } +} + .agent-ready-orbit, .agent-ready-core { transform-box: fill-box; diff --git a/tests/browser/agent-acrylic.e2e.ts b/tests/browser/agent-acrylic.e2e.ts index 2f2558d..1f9685f 100644 --- a/tests/browser/agent-acrylic.e2e.ts +++ b/tests/browser/agent-acrylic.e2e.ts @@ -31,35 +31,32 @@ async function waitForAnimations(locator: Locator) { }); } -test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => { +test("desktop Agent lives in the solid Main Frame navigation pane", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); const topBar = page.locator("header.acrylic-navigation"); + const navigation = page.getByRole("complementary", { name: "工作台导航" }); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); - const agentHeader = agentPanel.locator(".agent-panel-integrated-header"); - const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas"); + const agentHeader = agentPanel.locator(".agent-panel-header"); + const agentConversation = agentPanel.getByRole("log"); const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll"); - const agentComposer = agentPanel.locator(".agent-panel-composer"); + const agentControls = agentPanel.locator(".agent-panel-band").last(); await expect(topBar).toBeVisible(); + await expect(navigation).toBeVisible(); await expect(agentPanel).toBeVisible(); await expect(agentHeader).toBeVisible(); - await expect(agentComposer).toBeVisible(); + await expect(agentControls).toBeVisible(); await waitForAnimations(agentPanel); await expectAcrylicSurface(topBar); - await expectAcrylicSurface(agentPanel, ".agent-panel-composer"); - await expectIntegratedHeader(agentHeader); - await expectContextualAcrylicSurface(agentComposer, { - backdropFilter: "blur(", - maximumAlpha: 0.9, - minimumAlpha: 0.84 - }); - await expectLightweightMaterialContext(agentConversation); - await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control")); - await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)"); - await expect(agentComposer).toHaveCSS("border-radius", "16px"); - await expect(agentComposer).toHaveCSS("border-top-width", "0px"); + await expect(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)"); + await expect(navigation).toHaveCSS("backdrop-filter", "none"); + await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)"); + await expect(agentPanel).toHaveCSS("backdrop-filter", "none"); + await expect(agentPanel).toHaveCSS("border-radius", "0px"); + await expect(agentPanel).toHaveCSS("box-shadow", "none"); + await expect(agentControls).toHaveCSS("border-top-width", "1px"); await expect( agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。") ).toHaveCSS("color", "rgb(51, 65, 85)"); @@ -69,66 +66,30 @@ test("desktop Agent layers floating controls inside one acrylic panel", async ({ const panelBox = await agentPanel.boundingBox(); const conversationBox = await agentConversation.boundingBox(); const scrollBox = await agentConversationScroll.boundingBox(); - const composerBox = await agentComposer.boundingBox(); expect(panelBox).not.toBeNull(); expect(conversationBox).not.toBeNull(); expect(scrollBox).not.toBeNull(); - expect(composerBox).not.toBeNull(); + expect(panelBox!.x).toBe(48); + expect(panelBox!.width).toBe(319); expect( - Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4)) - ).toBeLessThanOrEqual(1); - expect( - Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height)) - ).toBeLessThanOrEqual(1); - expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height); - expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21); - expect( - Math.abs( - composerBox!.x - - panelBox!.x - - (panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width) - ) + Math.abs(conversationBox!.x - panelBox!.x) ).toBeLessThanOrEqual(1); await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges"); - const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer); + const beforeFocus = await getSurfacePresentation(agentPanel); await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus(); await expect - .poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer)) + .poll(() => getSurfacePresentation(agentPanel)) .toEqual(beforeFocus); }); -async function expectIntegratedHeader(header: Locator) { - const composition = await header.evaluate((element) => { - const style = getComputedStyle(element); - - return { - backdropFilter: style.backdropFilter, - backgroundColor: style.backgroundColor, - borderTopWidth: style.borderTopWidth, - borderRadius: style.borderRadius - }; - }); - - expect(composition.backdropFilter).toBe("none"); - expect(getAlpha(composition.backgroundColor)).toBe(0); - expect(composition.borderTopWidth).toBe("0px"); - expect(composition.borderRadius).toBe("0px"); -} - -test("desktop Agent header expands into one attached acrylic history surface", async ({ page }) => { +test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); - const agentHeader = agentPanel.locator(".agent-panel-integrated-header"); - const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas"); const historyButton = agentPanel.getByRole("button", { name: "打开 Agent 历史记录" }); - const headerBefore = await agentHeader.boundingBox(); - const conversationBefore = await agentConversation.boundingBox(); - const historyButtonPresentationBefore = await getSurfacePresentation(historyButton); - const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel); await historyButton.click(); @@ -136,51 +97,7 @@ test("desktop Agent header expands into one attached acrylic history surface", a await expect(history).toBeVisible(); await waitForAnimations(history); await expect(historyButton).toHaveAttribute("aria-expanded", "true"); - await page.mouse.move(720, 320); - await expectAttachedHistoryExtension(history); - await expectTransparentExpandedHeader(agentHeader, agentPanel); await expectUnifiedHistoryStructure(history); - await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore); - await expect - .poll(() => getSurfacePresentation(historyButton)) - .toEqual(historyButtonPresentationBefore); - - const sessionTrigger = history.locator(".agent-history-session-trigger").first(); - const sessionTriggerBox = await sessionTrigger.boundingBox(); - const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox(); - expect(sessionTriggerBox).not.toBeNull(); - expect(historyHeadingBox).not.toBeNull(); - await page.mouse.move( - sessionTriggerBox!.x + sessionTriggerBox!.width / 2, - sessionTriggerBox!.y + sessionTriggerBox!.height / 2 - ); - await page.mouse.down(); - await expect(sessionTrigger).toHaveCSS("outline-style", "none"); - await page.mouse.move( - historyHeadingBox!.x + historyHeadingBox!.width / 2, - historyHeadingBox!.y + historyHeadingBox!.height / 2 - ); - await page.mouse.up(); - - const headerBox = await agentHeader.boundingBox(); - const agentPanelBox = await agentPanel.boundingBox(); - const historyBox = await history.boundingBox(); - const conversationAfter = await agentConversation.boundingBox(); - expect(headerBefore).not.toBeNull(); - expect(headerBox).not.toBeNull(); - expect(agentPanelBox).not.toBeNull(); - expect(historyBox).not.toBeNull(); - expect(conversationBefore).not.toBeNull(); - expect(conversationAfter).not.toBeNull(); - expectRoundedValue(headerBox!.y, headerBefore!.y); - expect(headerBox!.height).toBeGreaterThan(headerBefore!.height); - expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height); - expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1); - expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1); - expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2); - expectRoundedValue(historyBox!.x, headerBox!.x); - expectRoundedValue(historyBox!.width, headerBox!.width); - expectRoundedBox(conversationAfter!, conversationBefore!); await history.getByRole("button", { name: "重命名对话" }).first().click(); const renameInput = history.locator("input").first(); @@ -197,12 +114,6 @@ test("desktop Agent header expands into one attached acrylic history surface", a await expect(history).toBeHidden(); await expect(historyButton).toBeFocused(); await expect(historyButton).toHaveAttribute("aria-expanded", "false"); - await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore); - - await historyButton.click(); - await expect(history).toBeVisible(); - await page.mouse.click(720, 320); - await expect(history).toBeHidden(); }); test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => { @@ -268,15 +179,16 @@ test("mobile Agent mirrors the desktop floating history surface", async ({ page await expect(historyButton).toBeFocused(); }); -test("Agent floating acrylic respects forced color mode", async ({ page }) => { +test("Main Frame navigation respects forced color mode", async ({ page }) => { await page.emulateMedia({ forcedColors: "active" }); await page.goto("/", { waitUntil: "domcontentloaded" }); - const composer = page.locator(".agent-panel-composer"); - await expect(composer).toBeVisible(); - await expect(composer).toHaveCSS("backdrop-filter", "none"); - await expect(composer).toHaveCSS("background-image", "none"); - await expect(composer).toHaveCSS("box-shadow", "none"); + const navigation = page.getByRole("complementary", { name: "工作台导航" }); + const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" }); + await expect(navigation).toBeVisible(); + await expect(navigation).toHaveCSS("backdrop-filter", "none"); + await expect(navigation).toHaveCSS("box-shadow", "none"); + await expect(taskbar).toHaveCSS("box-shadow", "none"); }); async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) { diff --git a/tests/browser/agent-panel-resize.e2e.ts b/tests/browser/agent-panel-resize.e2e.ts index 083d162..719ff50 100644 --- a/tests/browser/agent-panel-resize.e2e.ts +++ b/tests/browser/agent-panel-resize.e2e.ts @@ -1,45 +1,51 @@ import { expect, test } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; -test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => { +test("unified workbench navigation collapses and restores the main workspace", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); - const panel = page.locator('aside[aria-label="Agent 命令面板"]'); - const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); - await expect(panel).toBeVisible(); - await expect(resizeHandle).toBeVisible(); + const navigation = page.getByRole("complementary", { name: "工作台导航" }); + const workspace = page.locator("#main-workspace"); + await expect(navigation).toHaveCSS("width", "368px"); + await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368); - const handleBox = await resizeHandle.boundingBox(); - expect(handleBox).not.toBeNull(); + await page.getByRole("button", { name: "折叠工作台导航" }).click(); + await expect(navigation).toHaveCSS("width", "48px"); + await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48); - await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2); - await page.mouse.down(); - await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 }); - await page.mouse.up(); - - await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676); - - await resizeHandle.focus(); - await page.keyboard.press("Home"); - await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500); - await page.keyboard.press("ArrowRight"); - await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516); - await page.keyboard.press("End"); - await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676); + await page.getByRole("button", { name: "展开工作台导航" }).click(); + await expect(navigation).toHaveCSS("width", "368px"); + await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368); }); -test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => { +test("unified navigation switches between Agent and condition panes", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); + const mainFrame = page.getByTestId("workbench-main-frame"); + await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); + const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应"); + await prompt.fill("保留尚未发送的诊断问题"); + await mainFrame.getByRole("button", { name: "工况任务", exact: true }).click(); + const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); + await expect(conditionPanel).toBeVisible(); + await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeHidden(); + await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); + await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible(); + await conditionPanel.getByRole("button", { name: "收起工况任务" }).click(); - const rail = page.locator('aside[aria-label="Agent 折叠栏"]'); - const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ }); - await expect(rail).toBeVisible(); - await expect(rail).toHaveCSS("width", "72px"); - await expect(expandButton).toHaveAttribute("title", /当前|正在/); + await mainFrame.getByRole("button", { name: "Agent", exact: true }).click(); + await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); + await expect(prompt).toHaveValue("保留尚未发送的诊断问题"); - await expandButton.click(); + const topBarConditionToggle = page + .locator("header.acrylic-navigation") + .getByRole("button", { name: "工况任务", exact: true }); + await page.getByRole("button", { name: "折叠工作台导航" }).click(); + await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "48px"); + await topBarConditionToggle.click(); + await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "368px"); + await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible(); + await topBarConditionToggle.click(); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); }); diff --git a/tests/browser/workbench-visual.e2e.ts b/tests/browser/workbench-visual.e2e.ts index 8a37a0c..b34ae19 100644 --- a/tests/browser/workbench-visual.e2e.ts +++ b/tests/browser/workbench-visual.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; const EMPTY_RASTER_TILE = Buffer.from( @@ -6,24 +6,19 @@ const EMPTY_RASTER_TILE = Buffer.from( "base64" ); -test.describe("neutral blue mist workbench", () => { +test.describe("Main Frame workbench", () => { test.beforeEach(async ({ page }) => { await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); await page.emulateMedia({ reducedMotion: "reduce" }); await prepareDeterministicWorkbench(page); }); - test("desktop light basemap keeps both floating panels legible", async ({ page }) => { + test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await openWorkbench(page); - await expectDesktopFloatingGeometry(page); - await expectAcrylicAlphas(page, { - navigation: 0.64, - agentPanel: 0.82, - conditionPanel: 0.56, - control: 0.74 - }); + await expectDesktopMainFrameGeometry(page); + await expectSolidMainFrameMaterials(page); await expectLoadedChineseFont(page); await expectBodyContrast(page); await expect(page).toHaveScreenshot("workbench-desktop-light.png", { @@ -32,42 +27,35 @@ test.describe("neutral blue mist workbench", () => { }); }); - test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => { + test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await openWorkbench(page); await selectBasemap(page, "影像"); await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite"); - await expectDesktopFloatingGeometry(page); - await expectAcrylicAlphas(page, { - navigation: 0.8, - agentPanel: 0.88, - conditionPanel: 0.72, - control: 0.86 - }); + await expectDesktopMainFrameGeometry(page); + await expectSolidMainFrameMaterials(page); await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", { animations: "disabled", maxDiffPixelRatio: 0.01 }); }); - test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => { + test("wide desktop expands the map document when navigation is collapsed", async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); await openWorkbench(page); - const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); - const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); - const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); + const navigation = page.getByRole("complementary", { name: "工作台导航" }); + const workspace = page.locator("#main-workspace"); + const expandedWorkspace = await workspace.boundingBox(); + expect(expandedWorkspace).not.toBeNull(); + await expect(navigation).toHaveCSS("width", "368px"); - await resizeHandle.focus(); - await page.keyboard.press("End"); - await expect(agentPanel).toHaveCSS("width", "720px"); - await expectMapCorridor(agentPanel, conditionPanel, 680); - - await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); - await expect(conditionPanel).toHaveCSS("width", "960px"); - await expect(agentPanel).toHaveCSS("width", "628px"); - await expectMapCorridor(agentPanel, conditionPanel, 256); + await page.getByRole("button", { name: "折叠工作台导航" }).click(); + await expect(navigation).toHaveCSS("width", "48px"); + await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48); + await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872); + expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width); await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", { animations: "disabled", maxDiffPixelRatio: 0.01 @@ -201,41 +189,59 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") { ); } -async function expectDesktopFloatingGeometry(page: Page) { +async function expectDesktopMainFrameGeometry(page: Page) { + const navigation = page.getByRole("complementary", { name: "工作台导航" }); + const workspace = page.locator("#main-workspace"); + const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" }); + const mapWindow = page.locator("#workspace-map"); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); - const agentHeader = agentPanel.locator(".agent-panel-integrated-header"); - const agentComposer = agentPanel.locator(".agent-panel-composer"); - const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); - - await expect(agentPanel).toHaveCSS("width", "500px"); - await expect(conditionPanel).toHaveCSS("width", "432px"); - await expect(agentPanel).toHaveCSS("border-radius", "16px"); - await expect(agentHeader).toHaveCSS("border-radius", "0px"); - await expect(agentComposer).toHaveCSS("border-radius", "16px"); - await expect(agentComposer).toHaveCSS("border-top-width", "0px"); - await expect(conditionPanel).toHaveCSS("border-radius", "16px"); + await expect(navigation).toHaveCSS("width", "368px"); + await expect(agentPanel).toHaveCSS("border-radius", "0px"); + await expect(mapWindow).toHaveCSS("border-radius", "0px"); + const navigationBox = await navigation.boundingBox(); + const workspaceBox = await workspace.boundingBox(); + const taskbarBox = await taskbar.boundingBox(); + const mapBox = await mapWindow.boundingBox(); const agentBox = await agentPanel.boundingBox(); - const conditionBox = await conditionPanel.boundingBox(); + expect(navigationBox).not.toBeNull(); + expect(workspaceBox).not.toBeNull(); + expect(taskbarBox).not.toBeNull(); + expect(mapBox).not.toBeNull(); expect(agentBox).not.toBeNull(); - expect(conditionBox).not.toBeNull(); - expect(agentBox!.x).toBe(12); - expect(agentBox!.y).toBe(96); - expect(agentBox!.y + agentBox!.height).toBe(884); - expect(conditionBox!.x).toBe(944); - expect(conditionBox!.y).toBe(96); + expect(navigationBox!.x).toBe(0); + expect(navigationBox!.y).toBe(56); + expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width); + expect(workspaceBox!.y).toBe(navigationBox!.y); + expect(taskbarBox!.x).toBe(workspaceBox!.x); + expect(taskbarBox!.y).toBe(workspaceBox!.y + workspaceBox!.height); + expect(mapBox).toEqual(workspaceBox); + expect(agentBox!.x).toBeGreaterThanOrEqual(48); + expect(agentBox!.x).toBeLessThanOrEqual(49); + expect( + Math.abs(agentBox!.x + agentBox!.width - (navigationBox!.x + navigationBox!.width)) + ).toBeLessThanOrEqual(1); } -async function expectMapCorridor( - agentPanel: Locator, - conditionPanel: Locator, - minimumWidth: number -) { - const agentBox = await agentPanel.boundingBox(); - const conditionBox = await conditionPanel.boundingBox(); - expect(agentBox).not.toBeNull(); - expect(conditionBox).not.toBeNull(); - expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth); +async function expectSolidMainFrameMaterials(page: Page) { + const materials = await page.evaluate(() => { + const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!); + const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!); + return { + navigationBackdrop: navigation.backdropFilter, + navigationBackground: navigation.backgroundColor, + agentBackdrop: agent.backdropFilter, + agentBackground: agent.backgroundColor, + agentShadow: agent.boxShadow + }; + }); + expect(materials).toEqual({ + navigationBackdrop: "none", + navigationBackground: "rgb(237, 241, 245)", + agentBackdrop: "none", + agentBackground: "rgb(247, 249, 251)", + agentShadow: "none" + }); } async function expectLoadedChineseFont(page: Page) { @@ -246,39 +252,6 @@ async function expectLoadedChineseFont(page: Page) { .toBe(true); } -async function expectAcrylicAlphas( - page: Page, - expected: { - navigation: number; - agentPanel: number; - conditionPanel: number; - control: number; - } -) { - const alphas = await page.evaluate(() => { - return { - navigation: alphaOf(document.querySelector(".acrylic-navigation")), - agentPanel: alphaOf(document.querySelector('aside[aria-label="Agent 命令面板"]')), - conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")), - control: alphaOf(document.querySelector(".acrylic-control")) - }; - - function alphaOf(element: Element | null) { - if (!element) { - return 1; - } - const color = getComputedStyle(element).backgroundColor; - const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? []; - return channels[3] ?? 1; - } - }); - - expect(alphas.navigation).toBeCloseTo(expected.navigation, 2); - expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2); - expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2); - expect(alphas.control).toBeCloseTo(expected.control, 2); -} - async function expectBodyContrast(page: Page) { const contrast = await page .getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。") diff --git a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-light-chromium-linux.png b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-light-chromium-linux.png index 421c8c5..2586408 100644 Binary files a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-light-chromium-linux.png and b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-light-chromium-linux.png differ diff --git a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-satellite-chromium-linux.png b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-satellite-chromium-linux.png index 2996e06..218000c 100644 Binary files a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-satellite-chromium-linux.png and b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-satellite-chromium-linux.png differ diff --git a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-wide-max-agent-chromium-linux.png b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-wide-max-agent-chromium-linux.png index a61e45d..11011e4 100644 Binary files a/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-wide-max-agent-chromium-linux.png and b/tests/browser/workbench-visual.e2e.ts-snapshots/workbench-desktop-wide-max-agent-chromium-linux.png differ diff --git a/tests/browser/workspace-main-frame.e2e.ts b/tests/browser/workspace-main-frame.e2e.ts new file mode 100644 index 0000000..3c6f09d --- /dev/null +++ b/tests/browser/workspace-main-frame.e2e.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mockAgentApi } from "./support/mock-agent-api"; + +test("temporary Agent analysis windows replace and restore the map workspace", async ({ page }) => { + await prepareMainFrame(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const frame = page.getByTestId("workbench-main-frame"); + const mapWindow = page.locator("#workspace-map"); + const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first(); + + await expect(frame).toBeVisible(); + await expect(mapWindow).toBeVisible(); + await expect(testButton).toBeVisible(); + + await testButton.click(); + await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible(); + await expect(mapWindow).toBeHidden(); + + await testButton.click(); + await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible(); + await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible(); + + await page.getByRole("button", { name: "最小化供水服务分区平衡分析" }).click(); + await expect(page.getByRole("button", { name: "恢复供水服务分区平衡分析" })).toBeVisible(); + await page.getByRole("button", { name: "恢复供水服务分区平衡分析" }).click(); + await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "关闭并销毁供水服务分区平衡分析" }).click(); + await page.getByRole("button", { name: "关闭并销毁北辰分区压力异常诊断" }).click(); + await expect(mapWindow).toBeVisible(); + await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0); + await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible(); +}); + +test("the map document floats and minimizes to its taskbar entry", async ({ page }) => { + await prepareMainFrame(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const workspace = page.locator("#main-workspace"); + const mapWindow = page.locator("#workspace-map"); + const dockedBox = await workspace.boundingBox(); + expect(dockedBox).not.toBeNull(); + + await page.getByRole("button", { name: "浮动供水管网地图" }).click(); + const floatingBox = await mapWindow.boundingBox(); + expect(floatingBox).not.toBeNull(); + expect(floatingBox!.width).toBeLessThan(dockedBox!.width); + expect(floatingBox!.height).toBeLessThan(dockedBox!.height); + await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible(); + + await page.getByRole("button", { name: "最小化供水管网地图" }).click(); + await expect(mapWindow).toBeHidden(); + const restoreTask = page.getByRole("button", { name: "恢复供水管网地图" }); + await expect(restoreTask).toBeVisible(); + + await restoreTask.click(); + await expect(mapWindow).toBeVisible(); + await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible(); +}); + +test("the test control cycles through three trusted document profiles", async ({ page }) => { + await prepareMainFrame(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first(); + + await testButton.click(); + await testButton.click(); + await testButton.click(); + + await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeAttached(); + await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeAttached(); + await expect(page.getByRole("heading", { name: "低压事件调度处置流程", exact: true })).toBeVisible(); + await expect(page.getByText("3 / 6 个临时分析窗")).toBeVisible(); +}); + +test("temporary documents survive a mobile breakpoint round trip", async ({ page }) => { + await prepareMainFrame(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click(); + + const analysisHeading = page.locator("h2", { hasText: "北辰分区压力异常诊断" }); + await expect(analysisHeading).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator("#workspace-map")).toBeVisible(); + await expect(analysisHeading).toBeAttached(); + await expect(analysisHeading).toBeHidden(); + + await page.setViewportSize({ width: 1440, height: 900 }); + await expect(analysisHeading).toBeVisible(); + await expect(page.locator("#workspace-map")).toBeHidden(); +}); + +async function prepareMainFrame(page: Page) { + await mockAgentApi(page); + await page.route("**/runtime-config.js", async (route) => { + await route.fulfill({ + contentType: "application/javascript", + body: `globalThis.__TJWATER_CONFIG__ = { + TJWATER_AUTH_MODE: "disabled", + TJWATER_MAPBOX_ACCESS_TOKEN: "", + TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver", + TJWATER_GEOSERVER_WORKSPACE: "tjwater", + TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", + TJWATER_ENABLE_DEV_PANEL: "true", + TJWATER_ENABLE_MSW: "false" + };` + }); + }); + await page.route("https://workspace-map.invalid/**", async (route) => { + await route.fulfill({ status: 204 }); + }); +}