feat: add adaptive agent workspace windows

This commit is contained in:
2026-08-19 12:57:37 +08:00
parent d08cf2abc1
commit f58e0a70f4
18 changed files with 1797 additions and 309 deletions
+13 -6
View File
@@ -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();
});
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(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({
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
mobileSheet
compactPresentation
? "flex flex-1"
: expanded
? "scheduled-feed-layout-expanded flex-1"
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
<div
className={cn(
"relative min-h-0 min-w-0 overflow-hidden",
mobileSheet
compactPresentation
? expanded
? "scheduled-feed-detail-enter h-full w-full"
: "hidden"
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
mobileSheet && (expanded ? "hidden" : "h-full w-full")
compactPresentation && (expanded ? "hidden" : "h-full w-full")
)}
>
<ConditionTimelinePanel
@@ -166,7 +166,18 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
});
let resizeFrame: number | null = null;
const resizeMap = () => 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;
+97 -62
View File
@@ -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<HTMLDivElement | 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 [impactVisible, setImpactVisible] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(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
}
>
<div
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="AquaDispatch v1.0"
@@ -778,14 +797,14 @@ export function MapWorkbenchPage({
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
conditionFeedVisible={isLargeScreen ? mainFrameSection === "conditions" && !mainFrameNavigationCollapsed : mobileSheet === "condition"}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={toggleConditionFeedForViewport}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
@@ -799,50 +818,73 @@ export function MapWorkbenchPage({
onLogout={onLogout}
/>
<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}
/>
{!devPanelOpen ? (
<div className="absolute right-2 top-24 z-20 hidden lg:block">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
) : null}
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
<WorkbenchMainFrame
testEnabled={devPanelEnabled}
activeSection={mainFrameSection}
navigationCollapsed={mainFrameNavigationCollapsed}
onActiveSectionChange={setMainFrameSection}
onNavigationCollapsedChange={setMainFrameNavigationCollapsed}
renderAgentPanel={(onCollapse) => (
<AgentCommandPanel
{...agentPanelProps}
presentation="desktop-dock"
onCollapse={onCollapse}
/>
)}
conditionsPanel={(
<ScheduledConditionFeed
presentation="desktop-floating"
presentation="desktop-dock"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible={shouldShowConditionFeed}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
{!devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
) : null}
)}
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={(
<div
ref={mapContainerRef}
className="map-grid absolute inset-0"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
)}
mapOverlay={(
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
<div className="absolute bottom-0 right-0 flex flex-col items-end gap-2">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
</div>
</div>
)}
/>
{devPanelEnabled && devPanelOpen ? (
<MapDevPanel
@@ -862,13 +904,6 @@ export function MapWorkbenchPage({
</div>
) : null}
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
</div>
<AnimatePresence initial={false}>
{shouldShowTaskTicker ? (
<motion.div
@@ -0,0 +1,211 @@
import type { AnalysisDocument } from "./workspace-model";
type FixtureFactory = (id: string, generatedAt: string) => 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 })
);
}
@@ -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 (
<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">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent
</div>
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
<Clock3 size={13} aria-hidden="true" />
{document.generatedAt}
</div>
</header>
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
{document.blocks.map((block) => (
<AnalysisBlockView key={block.id} block={block} />
))}
</div>
</article>
);
}
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 (
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
{block.metrics.map((metric) => (
<MetricTile key={metric.label} metric={metric} />
))}
</div>
</section>
);
}
if (block.kind === "chart") {
return (
<AnalysisSection block={block} className={spanClass}>
<div className="h-[258px] min-h-0">
<ReactECharts
notMerge
lazyUpdate
style={{ width: "100%", height: "100%" }}
option={createChartOption(block)}
/>
</div>
</AnalysisSection>
);
}
if (block.kind === "process-flow") {
return (
<AnalysisSection block={block} className={spanClass}>
<ol className="space-y-0" aria-label={block.title}>
{block.nodes.map((node, index) => (
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
{index < block.nodes.length - 1 ? (
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" />
) : null}
<span
className={cn(
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
node.status === "complete" && "bg-emerald-100 text-emerald-700",
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
node.status === "pending" && "bg-slate-100 text-slate-500"
)}
>
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 pt-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700"></span> : null}
</div>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div>
</li>
))}
</ol>
</AnalysisSection>
);
}
if (block.kind === "data-table") {
return (
<AnalysisSection block={block} className={spanClass}>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
{block.columns.map((column) => (
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
{block.columns.map((column) => (
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</AnalysisSection>
);
}
return (
<AnalysisSection block={block} className={spanClass}>
<div className="space-y-3 text-sm leading-7 text-slate-700">
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</AnalysisSection>
);
}
function AnalysisSection({
block,
className,
children
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
className: string;
children: React.ReactNode;
}) {
return (
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}>
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"><FileSearch size={16} aria-hidden="true" /></span>
<div className="min-w-0">
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
</div>
</header>
<div className="p-4">{children}</div>
</section>
);
}
function MetricTile({ metric }: { metric: AnalysisMetric }) {
return (
<div className="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
</div>
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p>
</div>
);
}
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
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";
}
@@ -0,0 +1,324 @@
import {
Bot,
CalendarClock,
ChevronsLeft,
ChevronsRight,
FlaskConical,
Layers3,
Map as MapIcon,
PanelLeft,
SlidersHorizontal,
Sparkles
} from "lucide-react";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { AnalysisDocumentView } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
import {
MAX_ANALYSIS_WINDOWS,
closeAnalysisWindow,
createInitialWorkspaceState,
focusWorkspaceWindow,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
updateWorkspaceWindowRect,
type WorkspaceBounds
} from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window";
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
type WorkbenchMainFrameProps = {
renderAgentPanel: (onCollapse: () => void) => ReactNode;
conditionsPanel: ReactNode;
layersPanel: ReactNode;
toolsPanel: ReactNode;
mapContent: ReactNode;
mapOverlay?: ReactNode;
testEnabled: boolean;
activeSection: WorkbenchNavigationSection;
navigationCollapsed: boolean;
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
onNavigationCollapsedChange: (collapsed: boolean) => void;
};
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", icon: Bot },
{ id: "conditions", label: "工况任务", icon: CalendarClock },
{ id: "layers", label: "图层", icon: Layers3 },
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
];
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
export function WorkbenchMainFrame({
renderAgentPanel,
conditionsPanel,
layersPanel,
toolsPanel,
mapContent,
mapOverlay,
testEnabled,
activeSection,
navigationCollapsed,
onActiveSectionChange,
onNavigationCollapsedChange
}: WorkbenchMainFrameProps) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
const [desktop, setDesktop] = useState(false);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
useEffect(() => {
const element = workspaceRef.current;
if (!element) return;
const updateBounds = () => {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const nextBounds = { width: rect.width, height: rect.height };
setBounds(nextBounds);
setWorkspace((current) => resizeWorkspace(current, nextBounds));
}
};
updateBounds();
const observer = new ResizeObserver(updateBounds);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setDesktop(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const openTestDocument = useCallback(() => {
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
showMapNotice({
tone: "warning",
title: "临时窗口已达上限",
message: "请关闭一个 Agent 分析窗口后继续测试。"
});
return;
}
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]);
const navigationWidth = navigationCollapsed ? 48 : 368;
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
data-testid="workbench-main-frame"
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
>
<aside
aria-label="工作台导航"
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
style={{ width: navigationWidth }}
>
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
{NAVIGATION_ITEMS.map((item) => {
const Icon = item.icon;
const active = item.id === activeSection;
return (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={active}
title={item.label}
className={cn(
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
)}
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
>
<Icon size={19} aria-hidden="true" />
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
</button>
);
})}
<div className="mt-auto flex flex-col items-center gap-1">
{testEnabled ? (
<button
type="button"
aria-label="测试打开 Agent 多图表窗口"
title="测试打开 Agent 多图表窗口"
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={openTestDocument}
>
<FlaskConical size={19} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
>
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
</button>
</div>
</nav>
<div
aria-hidden={navigationCollapsed}
className={cn(
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
navigationCollapsed && "invisible"
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
</div>
{testEnabled ? (
<button
type="button"
onClick={openTestDocument}
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<Sparkles size={14} aria-hidden="true" />
</button>
) : null}
</header>
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
{desktop ? (
<>
<NavigationPanel active={activeSection === "agent"}>
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
</NavigationPanel>
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
</>
) : null}
</div>
</div>
</aside>
<div
ref={workspaceRef}
id="main-workspace"
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
>
<WorkspaceWindow
id={workspace.map.id}
title={workspace.map.title}
mode={desktop ? workspace.map.mode : "docked"}
rect={workspace.map.rect}
zIndex={workspace.map.zIndex}
active={workspace.activeWindowId === workspace.map.id}
kind="map"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
>
{mapContent}
{mapOverlay}
</WorkspaceWindow>
{workspace.analyses.map((window) => (
<WorkspaceWindow
key={window.id}
id={window.id}
title={window.title}
mode={window.mode}
rect={window.rect}
zIndex={window.zIndex}
active={workspace.activeWindowId === window.id}
kind="analysis"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
>
<AnalysisDocumentView document={window.document} />
</WorkspaceWindow>
))}
</div>
<div
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
role="toolbar"
aria-label="工作区窗口任务栏"
>
<TaskbarButton
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
minimized={workspace.map.mode === "minimized"}
icon={<MapIcon size={15} aria-hidden="true" />}
label="供水管网地图"
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
key={window.id}
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
minimized={window.mode === "minimized"}
icon={<Sparkles size={14} aria-hidden="true" />}
label={window.title}
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
/>
))}
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} </span>
</div>
{!desktop ? <span className="sr-only"></span> : null}
</div>
);
}
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
title={label}
className={cn(
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
)}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
return (
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
{children}
</div>
);
}
@@ -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);
});
});
@@ -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<Record<string, string | number>>;
}
| {
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<WorkspaceWindowMode, "docked">;
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);
}
@@ -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<HTMLElement>) => {
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 (
<section
id={id}
aria-label={title}
className={cn(
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900",
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
mode === "docked" ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
active ? "lg:ring-1 lg:ring-blue-500/40" : "lg:ring-1 lg:ring-slate-300/90"
)}
style={style}
onPointerDownCapture={onFocus}
>
<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 && "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>
{floating ? RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
aria-hidden="true"
className={cn("hidden lg:block", resizeHandleClass(direction))}
onPointerDown={(event) => beginPointerAction(event, direction)}
/>
)) : null}
</section>
);
}
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 {
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<ResizeDirection, string> = {
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]}`;
}
+43
View File
@@ -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;
+27 -115
View File
@@ -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) {
+35 -29
View File
@@ -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();
});
+66 -93
View File
@@ -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("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 174 KiB

+114
View File
@@ -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 });
});
}