feat: refactor artifact analysis workspace
This commit is contained in:
+2
-1
@@ -124,5 +124,6 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
|
||||
);
|
||||
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toBeVisible();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
artifactActive: boolean;
|
||||
conditionExpanded: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
@@ -23,6 +24,7 @@ export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
artifactActive,
|
||||
conditionExpanded,
|
||||
personaState,
|
||||
statusLabel,
|
||||
@@ -42,14 +44,20 @@ export function WorkbenchAgentPanels({
|
||||
return (
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-14 left-3 top-24 z-50 hidden max-w-[720px] lg:block"
|
||||
style={{ width: resize.width ?? defaultWidth }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-50 hidden lg:block",
|
||||
!artifactActive && panelOpen && "workbench-agent-dock",
|
||||
artifactActive && panelOpen
|
||||
? "bottom-4 left-3 top-20 max-w-[720px]"
|
||||
: "bottom-0 left-0 top-14"
|
||||
)}
|
||||
style={{ width: panelOpen ? resize.width ?? defaultWidth : 72 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-floating"
|
||||
presentation={artifactActive ? "desktop-floating" : "desktop-dock"}
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
@@ -73,11 +81,13 @@ export function WorkbenchAgentPanels({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -148,8 +148,8 @@ export function WorkbenchTopBar({
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="测试打开 Agent 多图表窗口"
|
||||
title="测试打开 Agent 多图表窗口"
|
||||
aria-label="生成 Agent 模拟分析成果"
|
||||
title="生成 Agent 模拟分析成果"
|
||||
data-selection-tone="soft"
|
||||
onClick={onCreateTestAnalysis}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
|
||||
@@ -43,6 +43,9 @@ describe("workbench responsive layout", () => {
|
||||
);
|
||||
|
||||
expect(result.current.isLargeScreen).toBe(true);
|
||||
expect(result.current.shouldShowConditionFeed).toBe(false);
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.shouldShowConditionFeed).toBe(true);
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
|
||||
@@ -4,7 +4,6 @@ import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
const CONDITION_FEED_EXIT_MS = 170;
|
||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||
const CONDITION_FEED_DEFAULT_MIN_WIDTH = 1280;
|
||||
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
||||
|
||||
type WorkbenchMobileSheet = "agent" | "condition" | null;
|
||||
@@ -32,7 +31,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||
);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
@@ -49,7 +48,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
};
|
||||
|
||||
handleViewportChange();
|
||||
setConditionFeedVisible(window.innerWidth >= CONDITION_FEED_DEFAULT_MIN_WIDTH);
|
||||
setConditionFeedVisible(false);
|
||||
mediaQuery.addEventListener("change", handleViewportChange);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
|
||||
|
||||
@@ -17,3 +17,14 @@ export type {
|
||||
LineLayerGroupStylePatch,
|
||||
PointLayerGroupStylePatch
|
||||
} from "./map/layer-group-style";
|
||||
export type {
|
||||
AnalysisArtifact,
|
||||
AnalysisArtifactAction,
|
||||
AnalysisBlock,
|
||||
ArtifactLifecycle,
|
||||
ArtifactMapRelation,
|
||||
ArtifactRequestedView,
|
||||
ArtifactType,
|
||||
ArtifactViewMode,
|
||||
WorkbenchSurfaceMode
|
||||
} from "./workspace/workspace-model";
|
||||
|
||||
@@ -44,9 +44,9 @@ import {
|
||||
} from "./components/toolbar-panel";
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
WorkbenchMainFrame,
|
||||
type WorkbenchMainFrameHandle
|
||||
WorkbenchMainFrame
|
||||
} from "./workspace/workbench-main-frame";
|
||||
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||
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";
|
||||
@@ -104,7 +104,6 @@ export function MapWorkbenchPage({
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mainFrameRef = useRef<WorkbenchMainFrameHandle | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
@@ -147,6 +146,8 @@ export function MapWorkbenchPage({
|
||||
onFrontendAction: handleFrontendAction,
|
||||
getAccessToken
|
||||
});
|
||||
const collapseAgentPanel = agent.collapsePanel;
|
||||
const expandAgentPanel = agent.expandPanel;
|
||||
const clearActiveTool = useCallback(() => {
|
||||
setActiveToolId(null);
|
||||
}, []);
|
||||
@@ -173,10 +174,22 @@ export function MapWorkbenchPage({
|
||||
activeToolOpen: activeToolId !== null,
|
||||
agentPanelOpen: agent.panelOpen,
|
||||
devPanelOpen,
|
||||
collapseAgentPanel: agent.collapsePanel,
|
||||
expandAgentPanel: agent.expandPanel,
|
||||
collapseAgentPanel,
|
||||
expandAgentPanel,
|
||||
onClearActiveTool: clearActiveTool
|
||||
});
|
||||
const artifactWorkspace = useArtifactWorkspace(viewportWidth);
|
||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||
const agentOverlaysWorkspace = artifactActive && leftPanelOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (artifactActive) {
|
||||
collapseAgentPanel();
|
||||
setDetailFeature(null);
|
||||
return;
|
||||
}
|
||||
expandAgentPanel();
|
||||
}, [artifactActive, collapseAgentPanel, expandAgentPanel]);
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
openAgentPanelForViewport();
|
||||
@@ -199,7 +212,7 @@ export function MapWorkbenchPage({
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
leftPanelOpen: agentOverlaysWorkspace,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
@@ -238,7 +251,11 @@ export function MapWorkbenchPage({
|
||||
});
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
!artifactActive
|
||||
&& mobileSheet === null
|
||||
&& !conditionFeedExpanded
|
||||
&& taskTickerVisible
|
||||
&& taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -618,7 +635,7 @@ export function MapWorkbenchPage({
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
leftPanelOpen,
|
||||
agentOverlaysWorkspace,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
@@ -776,7 +793,11 @@ export function MapWorkbenchPage({
|
||||
{
|
||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||
"--workbench-agent-current-width": `${
|
||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
artifactActive
|
||||
? WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
: leftPanelOpen
|
||||
? agentPanelWidth
|
||||
: WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -802,7 +823,7 @@ export function MapWorkbenchPage({
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
onCreateTestAnalysis={devPanelEnabled ? () => mainFrameRef.current?.openTestAnalysis() : undefined}
|
||||
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
@@ -811,7 +832,15 @@ export function MapWorkbenchPage({
|
||||
/>
|
||||
|
||||
<WorkbenchMainFrame
|
||||
ref={mainFrameRef}
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
|
||||
onOpenPendingArtifact={artifactWorkspace.openPreview}
|
||||
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
|
||||
onSetArtifactView={artifactWorkspace.setView}
|
||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
||||
mapContent={(
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
@@ -842,6 +871,7 @@ export function MapWorkbenchPage({
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
artifactActive={artifactActive}
|
||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
@@ -880,7 +910,7 @@ export function MapWorkbenchPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mobileSheet === null ? (
|
||||
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel
|
||||
{...toolbarPanelProps}
|
||||
@@ -912,7 +942,7 @@ export function MapWorkbenchPage({
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
||||
<div className={`absolute z-30 lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "bottom-[4.25rem] right-3" : "bottom-3 left-3 right-3"}`}>
|
||||
{mobileSheet === null ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@@ -924,11 +954,13 @@ export function MapWorkbenchPage({
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||
</button>
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="h-11 justify-center"
|
||||
/>
|
||||
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="h-11 justify-center"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { AnalysisDocument } from "./workspace-model";
|
||||
import type { AnalysisArtifact } from "./workspace-model";
|
||||
|
||||
type FixtureFactory = (id: string, generatedAt: string) => AnalysisDocument;
|
||||
type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
|
||||
|
||||
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "diagnosis",
|
||||
lifecycle: "reviewed",
|
||||
revision: 3,
|
||||
title: "北辰分区压力异常诊断",
|
||||
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
|
||||
scope: "北辰供水分区 · 最近 6 小时",
|
||||
confidence: "92.6%",
|
||||
generatedAt,
|
||||
preferredView: "map_split",
|
||||
mapRelation: "required",
|
||||
blocks: [
|
||||
{
|
||||
id: "pressure-metrics",
|
||||
@@ -65,14 +73,28 @@ const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
|
||||
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
|
||||
actions: [
|
||||
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
|
||||
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "metric_analysis",
|
||||
lifecycle: "draft",
|
||||
revision: 2,
|
||||
title: "供水服务分区平衡分析",
|
||||
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
|
||||
scope: "六个服务分区 · 今日峰值预测",
|
||||
confidence: "88.4%",
|
||||
generatedAt,
|
||||
preferredView: "focus",
|
||||
mapRelation: "none",
|
||||
blocks: [
|
||||
{
|
||||
id: "zone-metrics",
|
||||
@@ -129,14 +151,28 @@ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
|
||||
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
|
||||
actions: [
|
||||
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
|
||||
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "simulation",
|
||||
lifecycle: "published",
|
||||
revision: 5,
|
||||
title: "低压事件调度处置流程",
|
||||
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
|
||||
scope: "北辰低压事件 · 30 分钟模拟",
|
||||
confidence: "90.8%",
|
||||
generatedAt,
|
||||
preferredView: "map_split",
|
||||
mapRelation: "required",
|
||||
blocks: [
|
||||
{
|
||||
id: "flow-metrics",
|
||||
@@ -196,12 +232,18 @@ const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
|
||||
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
|
||||
actions: [
|
||||
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
|
||||
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
|
||||
|
||||
export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument {
|
||||
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
|
||||
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||
const now = new Date();
|
||||
return factory(
|
||||
|
||||
@@ -1,175 +1,425 @@
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Database,
|
||||
FileSearch,
|
||||
Focus,
|
||||
Map as MapIcon,
|
||||
Minimize2,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type {
|
||||
AnalysisArtifact,
|
||||
AnalysisArtifactAction,
|
||||
AnalysisBlock,
|
||||
AnalysisDocument,
|
||||
AnalysisMetric
|
||||
AnalysisMetric,
|
||||
ArtifactRequestedView,
|
||||
WorkbenchSurfaceMode
|
||||
} 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 lg:pl-16">
|
||||
<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>
|
||||
type AnalysisArtifactWorkspaceProps = {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
};
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
|
||||
{document.blocks.map((block) => (
|
||||
<AnalysisBlockView key={block.id} block={block} />
|
||||
))}
|
||||
export function AnalysisArtifactWorkspace({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
onSetView,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: AnalysisArtifactWorkspaceProps) {
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||||
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
|
||||
const selectedBlock = useMemo(
|
||||
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
|
||||
[artifact.blocks, selectedBlockId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBlockId(null);
|
||||
}, [artifact.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBlock) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSelectedBlockId(null);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedBlock]);
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-label={`${artifact.title}分析成果`}
|
||||
className="relative grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-[#eef2f6] text-slate-900"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<ArtifactHeader artifact={artifact} />
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||||
<section aria-labelledby="artifact-summary-heading">
|
||||
<div className="mb-3 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
|
||||
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
|
||||
</div>
|
||||
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
|
||||
</div>
|
||||
{metricBlock?.kind === "metric-grid" ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
|
||||
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
|
||||
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-3.5">
|
||||
{evidenceBlocks.map((block) => (
|
||||
<AnalysisBlockView
|
||||
key={block.id}
|
||||
block={block}
|
||||
selected={block.id === selectedBlockId}
|
||||
onSelect={() => setSelectedBlockId(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ArtifactProvenance artifact={artifact} />
|
||||
</div>
|
||||
|
||||
<ArtifactActionBar
|
||||
artifact={artifact}
|
||||
surfaceMode={surfaceMode}
|
||||
mapSplitAvailable={mapSplitAvailable}
|
||||
detailOpen={Boolean(selectedBlock)}
|
||||
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
|
||||
onSetView={onSetView}
|
||||
onCollapse={onCollapse}
|
||||
onDestroy={onDestroy}
|
||||
/>
|
||||
|
||||
{selectedBlock ? (
|
||||
<ArtifactDetailDrawer
|
||||
artifact={artifact}
|
||||
block={selectedBlock}
|
||||
onClose={() => setSelectedBlockId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</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} />
|
||||
))}
|
||||
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<header className="relative z-10 bg-white px-4 py-4 shadow-[0_1px_0_rgba(15,23,42,0.08),0_5px_18px_rgba(15,23,42,0.04)] xl:px-5">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Agent Artifact
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{artifactTypeLabel(artifact.type)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>R{artifact.revision}</span>
|
||||
</div>
|
||||
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
|
||||
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<LifecycleBadge lifecycle={artifact.lifecycle} />
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock3 size={13} aria-hidden="true" />
|
||||
{artifact.generatedAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||||
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
|
||||
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
|
||||
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisBlockView({
|
||||
block,
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const spanClass = block.span === 5
|
||||
? "col-span-12 2xl:col-span-5"
|
||||
: block.span === 7
|
||||
? "col-span-12 2xl:col-span-7"
|
||||
: "col-span-12";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`${block.id}-title`}
|
||||
className={cn(
|
||||
"group min-w-0 overflow-hidden bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]",
|
||||
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||||
spanClass,
|
||||
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
|
||||
aria-label={`查看${block.title}详情`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<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>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
|
||||
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
|
||||
</span>
|
||||
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="border-t border-slate-100 p-4">
|
||||
<AnalysisBlockBody block={block} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
|
||||
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>
|
||||
<div className="h-[258px] min-h-0">
|
||||
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</AnalysisSection>
|
||||
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
|
||||
}
|
||||
|
||||
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
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>)}
|
||||
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
|
||||
<div className="bg-[#e7edf3] px-4 py-3.5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" />数据来源</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
|
||||
</div>
|
||||
</AnalysisSection>
|
||||
<div className="bg-amber-50 px-4 py-3.5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" />分析限制</h3>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}>• {item}</li>)}</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisSection({
|
||||
function ArtifactDetailDrawer({
|
||||
artifact,
|
||||
block,
|
||||
className,
|
||||
children
|
||||
onClose
|
||||
}: {
|
||||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
artifact: AnalysisArtifact;
|
||||
block: AnalysisBlock;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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}
|
||||
<aside aria-label="证据详情" className="absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain bg-white shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
|
||||
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500">成果结论</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500">证据说明</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-100 p-4">
|
||||
<p className="text-xs font-semibold text-slate-700">版本上下文</p>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-slate-500">版本</dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
|
||||
<dt className="text-slate-500">范围</dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
|
||||
<dt className="text-slate-500">置信度</dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactActionBar({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
detailOpen,
|
||||
onToggleDetail,
|
||||
onSetView,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
detailOpen: boolean;
|
||||
onToggleDetail: () => void;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<footer className="relative z-20 flex min-h-14 items-center justify-between gap-3 bg-white px-3 shadow-[0_-1px_0_rgba(15,23,42,0.08),0_-6px_18px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{artifact.mapRelation !== "none" ? (
|
||||
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||||
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
|
||||
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||||
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
|
||||
证据详情
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||||
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
|
||||
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
|
||||
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
|
||||
>
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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="min-w-0 bg-white px-3.5 py-3 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>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
|
||||
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
|
||||
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
|
||||
}
|
||||
|
||||
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
|
||||
if (type === "diagnosis") return "异常诊断";
|
||||
if (type === "simulation") return "方案模拟";
|
||||
return "指标分析";
|
||||
}
|
||||
|
||||
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
|
||||
if (relation === "required") return "强关联";
|
||||
if (relation === "optional") return "可选";
|
||||
return "无空间依赖";
|
||||
}
|
||||
|
||||
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||
return {
|
||||
animationDuration: 260,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
|
||||
|
||||
export function ArtifactPeekBar({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
onOpen,
|
||||
onDestroy
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
onOpen: () => void;
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Agent 成果预览"
|
||||
className={cn(
|
||||
"pointer-events-auto absolute bottom-4 z-30 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 bg-slate-950 px-3 py-2.5 text-slate-100 shadow-[0_18px_44px_rgba(15,23,42,0.28)]",
|
||||
"bottom-16 left-1/2 -translate-x-1/2",
|
||||
surfaceMode === "map_split" && "2xl:left-[25%]"
|
||||
)}
|
||||
>
|
||||
<span className="hidden h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-300 sm:grid">
|
||||
<FileChartColumnIncreasing size={21} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold text-blue-300">
|
||||
<Sparkles size={12} aria-hidden="true" />
|
||||
新分析成果 · R{artifact.revision}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-50">{artifact.title}</p>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-slate-400">{artifact.summary}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-md bg-blue-500 px-3 text-xs font-semibold text-white hover:bg-blue-400 active:scale-95"
|
||||
>
|
||||
<ChevronUp size={15} aria-hidden="true" />
|
||||
打开成果
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="销毁预览成果"
|
||||
title="销毁预览成果"
|
||||
onClick={onDestroy}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 hover:bg-white/10 hover:text-rose-300 active:scale-95"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
collapseActiveArtifact,
|
||||
createInitialArtifactWorkspaceState,
|
||||
destroyActiveArtifact,
|
||||
destroyPendingArtifact,
|
||||
openPendingArtifact,
|
||||
previewAnalysisArtifact,
|
||||
requestArtifactView,
|
||||
resolveWorkbenchSurfaceMode,
|
||||
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||
type ArtifactRequestedView
|
||||
} from "./workspace-model";
|
||||
|
||||
export function useArtifactWorkspace(viewportWidth: number) {
|
||||
const [state, setState] = useState(createInitialArtifactWorkspaceState);
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
|
||||
const createTestArtifactPreview = useCallback(() => {
|
||||
const artifact = createAnalysisArtifactFixture(
|
||||
fixtureIndexRef.current,
|
||||
fixtureInstanceRef.current
|
||||
);
|
||||
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
|
||||
fixtureInstanceRef.current += 1;
|
||||
setState((current) => previewAnalysisArtifact(current, artifact));
|
||||
}, []);
|
||||
|
||||
const openPreview = useCallback(() => {
|
||||
setState(openPendingArtifact);
|
||||
}, []);
|
||||
|
||||
const collapseArtifact = useCallback(() => {
|
||||
setState(collapseActiveArtifact);
|
||||
}, []);
|
||||
|
||||
const destroyPreview = useCallback(() => {
|
||||
setState(destroyPendingArtifact);
|
||||
}, []);
|
||||
|
||||
const destroyArtifact = useCallback(() => {
|
||||
setState(destroyActiveArtifact);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((view: ArtifactRequestedView) => {
|
||||
setState((current) => requestArtifactView(current, view));
|
||||
}, []);
|
||||
|
||||
const surfaceMode = useMemo(
|
||||
() => resolveWorkbenchSurfaceMode(state, viewportWidth),
|
||||
[state, viewportWidth]
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
surfaceMode,
|
||||
mapSplitAvailable: viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||
createTestArtifactPreview,
|
||||
openPreview,
|
||||
collapseArtifact,
|
||||
destroyPreview,
|
||||
destroyArtifact,
|
||||
setView
|
||||
};
|
||||
}
|
||||
@@ -1,436 +1,109 @@
|
||||
import {
|
||||
Map as MapIcon,
|
||||
PanelsTopLeft,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AnalysisDocumentView } from "./analysis-document-view";
|
||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
focusWorkspaceWindow,
|
||||
maximizeWorkspaceWindow,
|
||||
minimizeWorkspaceWindow,
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type WorkspaceBounds,
|
||||
type WorkspaceLayoutMode,
|
||||
type WorkspaceRect,
|
||||
type WorkspaceState
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import type {
|
||||
AnalysisArtifact,
|
||||
ArtifactRequestedView,
|
||||
WorkbenchSurfaceMode
|
||||
} from "./workspace-model";
|
||||
import {
|
||||
getTaskViewColumnCount,
|
||||
getTaskViewPreviewRects,
|
||||
getTaskViewSourceRect,
|
||||
WORKSPACE_LAYOUT_LABELS
|
||||
} from "./workspace-task-view-layout";
|
||||
import { WorkspaceTaskView } from "./workspace-task-view";
|
||||
import { WorkspaceWindow } from "./workspace-window";
|
||||
|
||||
type WorkbenchMainFrameProps = {
|
||||
mapContent: ReactNode;
|
||||
mapOverlay?: ReactNode;
|
||||
activeArtifact: AnalysisArtifact | null;
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
onCollapseArtifact: () => void;
|
||||
onDestroyArtifact: () => void;
|
||||
};
|
||||
|
||||
export type WorkbenchMainFrameHandle = {
|
||||
openTestAnalysis: () => void;
|
||||
};
|
||||
|
||||
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
type WorkspaceDragState = {
|
||||
sourceId: string;
|
||||
sourceRect: WorkspaceRect;
|
||||
targetId: string | null;
|
||||
};
|
||||
|
||||
export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
|
||||
export function WorkbenchMainFrame({
|
||||
mapContent,
|
||||
mapOverlay
|
||||
}, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
||||
const [taskViewOpen, setTaskViewOpen] = useState(false);
|
||||
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
|
||||
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
const taskViewWindows = useMemo(
|
||||
() => [workspace.map, ...workspace.analyses],
|
||||
[workspace.analyses, workspace.map]
|
||||
);
|
||||
const taskViewPreviewRects = useMemo(
|
||||
() => getTaskViewPreviewRects(taskViewWindows.length, bounds),
|
||||
[bounds, taskViewWindows.length]
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (!taskViewOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement("已关闭任务视图");
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [taskViewOpen]);
|
||||
|
||||
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]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
|
||||
|
||||
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const root = workspaceRef.current;
|
||||
if (!root) return null;
|
||||
return [...root.querySelectorAll<HTMLElement>("[data-workspace-window-id]")]
|
||||
.filter((element) => element.dataset.workspaceWindowId !== sourceId)
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const inset = Math.min(12, rect.width / 4, rect.height / 4);
|
||||
return rect.width > 0 && rect.height > 0
|
||||
&& pointer.x >= rect.left + inset
|
||||
&& pointer.x <= rect.right - inset
|
||||
&& pointer.y >= rect.top + inset
|
||||
&& pointer.y <= rect.bottom - inset;
|
||||
})
|
||||
.sort((a, b) => Number(b.style.zIndex || 0) - Number(a.style.zIndex || 0))[0]
|
||||
?.dataset.workspaceWindowId ?? null;
|
||||
}, []);
|
||||
|
||||
const startWindowMove = useCallback((sourceId: string, sourceRect: WorkspaceRect) => {
|
||||
const next = { sourceId, sourceRect, targetId: null };
|
||||
dragStateRef.current = next;
|
||||
setDragState(next);
|
||||
setWorkspaceAnnouncement("");
|
||||
}, []);
|
||||
|
||||
const previewWindowMove = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const current = dragStateRef.current;
|
||||
if (!current || current.sourceId !== sourceId) return;
|
||||
const targetId = findSwapTarget(sourceId, pointer);
|
||||
if (current.targetId === targetId) return;
|
||||
const next = { ...current, targetId };
|
||||
dragStateRef.current = next;
|
||||
setDragState(next);
|
||||
}, [findSwapTarget]);
|
||||
|
||||
const finishWindowMove = useCallback((
|
||||
sourceId: string,
|
||||
rect: WorkspaceRect,
|
||||
pointer: { x: number; y: number }
|
||||
) => {
|
||||
const current = dragStateRef.current;
|
||||
const targetId = current?.sourceId === sourceId
|
||||
? current.targetId ?? findSwapTarget(sourceId, pointer)
|
||||
: findSwapTarget(sourceId, pointer);
|
||||
if (targetId) {
|
||||
setWorkspace((state) => swapWorkspaceWindows(state, sourceId, targetId, bounds));
|
||||
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}与${getWorkspaceWindowTitle(workspace, targetId)}已交换位置`);
|
||||
} else {
|
||||
setWorkspace((state) => updateWorkspaceWindowRect(state, sourceId, rect, bounds));
|
||||
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}已转为自由排列`);
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDragState(null);
|
||||
}, [bounds, findSwapTarget, workspace]);
|
||||
|
||||
const cancelWindowMove = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
setDragState(null);
|
||||
setWorkspaceAnnouncement("已取消窗口移动");
|
||||
}, []);
|
||||
|
||||
function focusTaskViewWindow(windowId: string) {
|
||||
setTaskViewFocusWindowId(windowId);
|
||||
window.requestAnimationFrame(() => {
|
||||
workspaceRef.current
|
||||
?.querySelector<HTMLButtonElement>(`[data-task-view-window-id="${windowId}"]`)
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function openTaskView() {
|
||||
const windowId = workspace.activeWindowId;
|
||||
setTaskViewFocusWindowId(windowId);
|
||||
setTaskViewOpen(true);
|
||||
setWorkspaceAnnouncement(`任务视图已打开,共 ${taskViewWindows.length} 个窗口`);
|
||||
window.requestAnimationFrame(() => focusTaskViewWindow(windowId));
|
||||
}
|
||||
|
||||
function closeTaskView() {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement("已关闭任务视图");
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function activateTaskViewWindow(windowId: string) {
|
||||
const title = getWorkspaceWindowTitle(workspace, windowId);
|
||||
setWorkspace((current) => maximizeWorkspaceWindow(current, windowId));
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement(`${title}已铺满主视图`);
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function applyTaskViewLayout(mode: WorkspaceLayoutMode) {
|
||||
setWorkspace((current) => applyWorkspaceLayout(
|
||||
focusWorkspaceWindow(current, taskViewFocusWindowId),
|
||||
mode,
|
||||
bounds
|
||||
));
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement(`已应用${WORKSPACE_LAYOUT_LABELS[mode]}布局`);
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function handleTaskViewWindowKeyDown(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
) {
|
||||
const columns = getTaskViewColumnCount(taskViewWindows.length);
|
||||
let nextIndex = index;
|
||||
if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1);
|
||||
else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1);
|
||||
else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns);
|
||||
else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns);
|
||||
else if (event.key === "Home") nextIndex = 0;
|
||||
else if (event.key === "End") nextIndex = taskViewWindows.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
const nextWindow = taskViewWindows[nextIndex];
|
||||
if (nextWindow) focusTaskViewWindow(nextWindow.id);
|
||||
}
|
||||
mapOverlay,
|
||||
activeArtifact,
|
||||
pendingArtifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
onCollapseArtifact,
|
||||
onDestroyArtifact
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = activeArtifact && surfaceMode !== "map_only";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
||||
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 lg:left-[var(--workbench-agent-current-width)]"
|
||||
data-testid="workbench-main-frame"
|
||||
>
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
id="main-workspace"
|
||||
data-layout-mode={workspace.layout.mode}
|
||||
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
|
||||
data-layout-mode={surfaceMode}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||
surfaceMode !== "map_split" && "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
swapTarget={dragState?.targetId === workspace.map.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[0] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
workspace.map.mode,
|
||||
workspace.map.rect,
|
||||
workspace.map.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[0],
|
||||
index: 0
|
||||
} : undefined}
|
||||
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, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(workspace.map.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(workspace.map.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(workspace.map.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</WorkspaceWindow>
|
||||
{activeArtifact && surfaceMode === "map_only" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetArtifactView("focus")}
|
||||
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
|
||||
>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
<FileChartColumnIncreasing size={15} aria-hidden="true" />
|
||||
返回分析成果
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{workspace.analyses.map((window, index) => (
|
||||
<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"
|
||||
swapTarget={dragState?.targetId === window.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[index + 1] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
window.mode,
|
||||
window.rect,
|
||||
window.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[index + 1],
|
||||
index: index + 1
|
||||
} : undefined}
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
|
||||
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(window.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
{activeArtifact ? (
|
||||
<section
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn("relative min-h-0 min-w-0 overflow-hidden", !showArtifact && "hidden")}
|
||||
>
|
||||
<AnalysisDocumentView document={window.document} />
|
||||
</WorkspaceWindow>
|
||||
))}
|
||||
|
||||
{taskViewOpen ? (
|
||||
<WorkspaceTaskView
|
||||
windows={taskViewWindows}
|
||||
previewRects={taskViewPreviewRects}
|
||||
focusedWindowId={taskViewFocusWindowId}
|
||||
layoutMode={workspace.layout.mode}
|
||||
onClose={closeTaskView}
|
||||
onFocusWindow={setTaskViewFocusWindowId}
|
||||
onSelectWindow={activateTaskViewWindow}
|
||||
onWindowKeyDown={handleTaskViewWindowKeyDown}
|
||||
onLayoutChange={applyTaskViewLayout}
|
||||
/>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
mapSplitAvailable={mapSplitAvailable}
|
||||
onSetView={onSetArtifactView}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-0 left-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:flex"
|
||||
role="toolbar"
|
||||
aria-label="工作区窗口任务栏"
|
||||
>
|
||||
<button
|
||||
ref={taskViewButtonRef}
|
||||
type="button"
|
||||
aria-label="任务视图"
|
||||
aria-pressed={taskViewOpen}
|
||||
title="任务视图"
|
||||
className={cn(
|
||||
"grid h-8 w-10 shrink-0 place-items-center rounded-md transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
taskViewOpen
|
||||
? "bg-blue-600 text-white shadow-[0_1px_3px_rgba(37,99,235,0.28)]"
|
||||
: "text-slate-700 hover:bg-white"
|
||||
)}
|
||||
onClick={taskViewOpen ? closeTaskView : openTaskView}
|
||||
>
|
||||
<PanelsTopLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px shrink-0 bg-slate-300" aria-hidden="true" />
|
||||
<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={() => {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
|
||||
}}
|
||||
{pendingArtifact ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
onOpen={onOpenPendingArtifact}
|
||||
onDestroy={onDestroyPendingArtifact}
|
||||
/>
|
||||
{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={() => {
|
||||
setTaskViewOpen(false);
|
||||
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>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
|
||||
{!desktop ? <span className="sr-only">移动端保持地图与抽屉工作台</span> : null}
|
||||
) : 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 getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
|
||||
if (windowId === state.map.id) return state.map.title;
|
||||
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
||||
}
|
||||
|
||||
@@ -1,230 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
maximizeWorkspaceWindow,
|
||||
minimizeWorkspaceWindow,
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
setWorkspacePrimaryWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type AnalysisDocument,
|
||||
type WorkspaceState
|
||||
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||
collapseActiveArtifact,
|
||||
createInitialArtifactWorkspaceState,
|
||||
destroyActiveArtifact,
|
||||
destroyPendingArtifact,
|
||||
openPendingArtifact,
|
||||
previewAnalysisArtifact,
|
||||
requestArtifactView,
|
||||
resolveWorkbenchSurfaceMode
|
||||
} from "./workspace-model";
|
||||
|
||||
const bounds = { width: 1180, height: 760 };
|
||||
describe("artifact workspace state", () => {
|
||||
it("previews generated artifacts without taking over the workspace", () => {
|
||||
const initial = createInitialArtifactWorkspaceState();
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const previewed = previewAnalysisArtifact(initial, artifact);
|
||||
|
||||
function documentAt(index: number): AnalysisDocument {
|
||||
return {
|
||||
id: `analysis-${index}`,
|
||||
title: `分析 ${index}`,
|
||||
subtitle: "测试",
|
||||
generatedAt: "10:40:00",
|
||||
blocks: []
|
||||
};
|
||||
}
|
||||
|
||||
function openDocuments(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => index + 1).reduce(
|
||||
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||
createInitialWorkspaceState(bounds)
|
||||
);
|
||||
}
|
||||
|
||||
function visibleIds(state: WorkspaceState) {
|
||||
return [state.map, ...state.analyses]
|
||||
.filter((window) => window.mode !== "minimized")
|
||||
.map((window) => window.id);
|
||||
}
|
||||
|
||||
describe("workspace window lifecycle", () => {
|
||||
it("keeps the map visible under 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("docked");
|
||||
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");
|
||||
expect(previewed.pendingArtifact).toBe(artifact);
|
||||
expect(previewed.activeArtifact).toBeNull();
|
||||
expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default");
|
||||
});
|
||||
|
||||
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 = openDocuments(MAX_ANALYSIS_WINDOWS);
|
||||
|
||||
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
|
||||
it("opens a map-related artifact in split mode only when enough width exists", () => {
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||
);
|
||||
|
||||
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);
|
||||
expect(opened.activeArtifact).toBe(artifact);
|
||||
expect(opened.pendingArtifact).toBeNull();
|
||||
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split");
|
||||
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace layout presets", () => {
|
||||
it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds);
|
||||
|
||||
expect(arranged.layout.mode).toBe("primary");
|
||||
expect(arranged.layout.orderedWindowIds[0]).toBe("analysis-5");
|
||||
expect(visibleIds(arranged)).toEqual(["workspace-map", "analysis-4", "analysis-5"]);
|
||||
expect(arranged.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
|
||||
|
||||
const primary = arranged.analyses.find((window) => window.id === "analysis-5");
|
||||
expect(primary?.rect.width).toBeGreaterThan(arranged.map.rect.width);
|
||||
expect(primary?.rect.height).toBe(bounds.height - 16);
|
||||
});
|
||||
|
||||
it("restores every window into a bounded grid", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(6), "grid", bounds);
|
||||
const windows = [arranged.map, ...arranged.analyses];
|
||||
|
||||
expect(windows).toHaveLength(7);
|
||||
expect(windows.every((window) => window.mode === "floating")).toBe(true);
|
||||
for (const window of windows) {
|
||||
expect(window.rect.x).toBeGreaterThanOrEqual(8);
|
||||
expect(window.rect.y).toBeGreaterThanOrEqual(8);
|
||||
expect(window.rect.x + window.rect.width).toBeLessThanOrEqual(bounds.width - 8);
|
||||
expect(window.rect.y + window.rect.height).toBeLessThanOrEqual(bounds.height - 8);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses two visible slots for split and restores all windows when changing presets", () => {
|
||||
const primary = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
|
||||
const split = applyWorkspaceLayout(primary, "split", bounds);
|
||||
|
||||
expect(visibleIds(split)).toHaveLength(2);
|
||||
expect(visibleIds(split)).toContain("workspace-map");
|
||||
expect(split.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
|
||||
it("keeps non-spatial artifacts focused at every desktop width", () => {
|
||||
const artifact = createAnalysisArtifactFixture(1, 0);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||
);
|
||||
|
||||
const grid = applyWorkspaceLayout(split, "grid", bounds);
|
||||
expect(visibleIds(grid)).toHaveLength(5);
|
||||
expect(artifact.mapRelation).toBe("none");
|
||||
expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
|
||||
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
|
||||
});
|
||||
|
||||
it("auto-inserts a new Agent result into the current layout", () => {
|
||||
const primary = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
|
||||
const opened = openAnalysisDocument(primary, documentAt(3), bounds);
|
||||
it("supports explicit focus and map restoration for spatial artifacts", () => {
|
||||
const artifact = createAnalysisArtifactFixture(2, 0);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||
);
|
||||
const focused = requestArtifactView(opened, "focus");
|
||||
const restored = requestArtifactView(focused, "map_split");
|
||||
|
||||
expect(opened.layout.mode).toBe("primary");
|
||||
expect(opened.layout.orderedWindowIds[0]).toBe("analysis-3");
|
||||
expect(opened.activeWindowId).toBe("analysis-3");
|
||||
expect(opened.map.mode).not.toBe("minimized");
|
||||
expect(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus");
|
||||
expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split");
|
||||
});
|
||||
|
||||
it("reflows after manual minimize, taskbar restore and workspace resize", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
|
||||
const minimized = minimizeWorkspaceWindow(arranged, "analysis-4", bounds);
|
||||
const replacement = minimized.analyses.find((window) => window.id === "analysis-3");
|
||||
|
||||
expect(minimized.analyses.find((window) => window.id === "analysis-4")?.minimizedByLayout).toBe(false);
|
||||
expect(replacement?.mode).toBe("floating");
|
||||
|
||||
const restored = restoreWorkspaceWindow(minimized, "analysis-4", bounds);
|
||||
expect(restored.layout.orderedWindowIds[0]).toBe("analysis-4");
|
||||
expect(restored.analyses.find((window) => window.id === "analysis-4")?.mode).toBe("floating");
|
||||
it("supports a map-only page while a narrow Artifact remains active", () => {
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||
);
|
||||
const mapOnly = requestArtifactView(opened, "map_only");
|
||||
|
||||
const resized = resizeWorkspace(restored, { width: 900, height: 620 });
|
||||
expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604);
|
||||
expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only");
|
||||
expect(mapOnly.activeArtifact).toBe(artifact);
|
||||
});
|
||||
|
||||
it("supports cascade layout and an explicit primary-window command", () => {
|
||||
const cascade = applyWorkspaceLayout(openDocuments(3), "cascade", bounds);
|
||||
expect(visibleIds(cascade)).toHaveLength(4);
|
||||
expect(cascade.analyses[0]?.rect.x).toBeGreaterThan(cascade.analyses[1]?.rect.x ?? 0);
|
||||
it("collapses the active artifact back into a peek", () => {
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||
);
|
||||
const collapsed = collapseActiveArtifact(opened);
|
||||
|
||||
const primary = applyWorkspaceLayout(cascade, "primary", bounds);
|
||||
const promoted = setWorkspacePrimaryWindow(primary, "analysis-1", bounds);
|
||||
expect(promoted.layout.orderedWindowIds[0]).toBe("analysis-1");
|
||||
expect(promoted.analyses.find((window) => window.id === "analysis-1")?.rect.width).toBeGreaterThan(promoted.map.rect.width);
|
||||
expect(collapsed.activeArtifact).toBeNull();
|
||||
expect(collapsed.pendingArtifact).toBe(artifact);
|
||||
expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace window exchange", () => {
|
||||
it("swaps layout slots and preserves the order after resize", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
|
||||
const mapRect = arranged.map.rect;
|
||||
const primaryRect = arranged.analyses.find((window) => window.id === "analysis-2")?.rect;
|
||||
const swapped = swapWorkspaceWindows(arranged, "workspace-map", "analysis-2", bounds);
|
||||
|
||||
expect(swapped.map.rect).toEqual(primaryRect);
|
||||
expect(swapped.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual(mapRect);
|
||||
|
||||
const resized = resizeWorkspace(swapped, { width: 1000, height: 700 });
|
||||
expect(resized.layout.orderedWindowIds.indexOf("workspace-map")).toBeLessThan(
|
||||
resized.layout.orderedWindowIds.indexOf("analysis-2")
|
||||
it("replaces the active artifact only after the new preview is opened", () => {
|
||||
const first = createAnalysisArtifactFixture(0, 0);
|
||||
const second = createAnalysisArtifactFixture(2, 1);
|
||||
const opened = openPendingArtifact(
|
||||
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), first)
|
||||
);
|
||||
expect(resized.map.rect.width).toBeGreaterThan(resized.analyses.find((window) => window.id === "analysis-2")!.rect.width);
|
||||
});
|
||||
const withNewPreview = previewAnalysisArtifact(opened, second);
|
||||
|
||||
it("exchanges full window roles and rectangles in free layout", () => {
|
||||
const initial = createInitialWorkspaceState(bounds);
|
||||
const mapRect = initial.map.rect;
|
||||
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
|
||||
const analysisRect = opened.analyses[0]!.rect;
|
||||
const restoredMap = { ...opened, map: { ...opened.map, mode: "docked" as const } };
|
||||
const swapped = swapWorkspaceWindows(restoredMap, "analysis-1", "workspace-map", bounds);
|
||||
|
||||
expect(swapped.analyses[0]?.mode).toBe("docked");
|
||||
expect(swapped.analyses[0]?.rect).toEqual(mapRect);
|
||||
expect(swapped.map.mode).toBe("floating");
|
||||
expect(swapped.map.rect).toEqual(analysisRect);
|
||||
expect(withNewPreview.activeArtifact).toBe(first);
|
||||
expect(withNewPreview.pendingArtifact).toBe(second);
|
||||
expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second);
|
||||
});
|
||||
|
||||
it("exits automatic layout when a window is placed in blank workspace", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(2), "grid", bounds);
|
||||
const moved = updateWorkspaceWindowRect(
|
||||
arranged,
|
||||
"analysis-2",
|
||||
{ x: 90, y: 70, width: 420, height: 360 },
|
||||
bounds
|
||||
);
|
||||
it("destroys pending and active artifacts independently", () => {
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
|
||||
const opened = openPendingArtifact(previewed);
|
||||
|
||||
expect(moved.layout.mode).toBe("free");
|
||||
expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({
|
||||
x: 90,
|
||||
y: 70,
|
||||
width: 420,
|
||||
height: 360
|
||||
});
|
||||
expect(destroyPendingArtifact(previewed).pendingArtifact).toBeNull();
|
||||
expect(destroyActiveArtifact(opened).activeArtifact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
export const MAX_ANALYSIS_WINDOWS = 6;
|
||||
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = 1360;
|
||||
|
||||
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 WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
|
||||
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||
export type ArtifactViewMode = "map_split" | "focus";
|
||||
export type ArtifactRequestedView = ArtifactViewMode | "map_only";
|
||||
export type ArtifactMapRelation = "required" | "optional" | "none";
|
||||
export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
|
||||
|
||||
export type AnalysisMetric = {
|
||||
label: string;
|
||||
@@ -78,548 +70,100 @@ export type AnalysisBlock =
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type AnalysisDocument = {
|
||||
export type AnalysisArtifactAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
tone: "primary" | "neutral" | "warning";
|
||||
};
|
||||
|
||||
export type AnalysisArtifact = {
|
||||
id: string;
|
||||
type: ArtifactType;
|
||||
lifecycle: ArtifactLifecycle;
|
||||
revision: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
scope: string;
|
||||
confidence: string;
|
||||
generatedAt: string;
|
||||
preferredView: ArtifactViewMode;
|
||||
mapRelation: ArtifactMapRelation;
|
||||
blocks: AnalysisBlock[];
|
||||
sources: string[];
|
||||
limitations: string[];
|
||||
actions: AnalysisArtifactAction[];
|
||||
};
|
||||
|
||||
type WorkspaceWindowState = {
|
||||
mode: WorkspaceWindowMode;
|
||||
rect: WorkspaceRect;
|
||||
restoreRect: WorkspaceRect;
|
||||
zIndex: number;
|
||||
minimizedByLayout: boolean;
|
||||
export type ArtifactWorkspaceState = {
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
activeArtifact: AnalysisArtifact | null;
|
||||
requestedView: ArtifactRequestedView;
|
||||
};
|
||||
|
||||
export type AnalysisWorkspaceWindow = WorkspaceWindowState & {
|
||||
id: string;
|
||||
kind: "agent-analysis";
|
||||
title: string;
|
||||
document: AnalysisDocument;
|
||||
};
|
||||
|
||||
export type MapWorkspaceWindow = WorkspaceWindowState & {
|
||||
id: "workspace-map";
|
||||
kind: "map";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
map: MapWorkspaceWindow;
|
||||
analyses: AnalysisWorkspaceWindow[];
|
||||
activeWindowId: string;
|
||||
nextZIndex: number;
|
||||
layout: {
|
||||
mode: WorkspaceLayoutMode;
|
||||
orderedWindowIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
const MAP_WINDOW_ID = "workspace-map";
|
||||
const LAYOUT_INSET = 8;
|
||||
const LAYOUT_GAP = 8;
|
||||
|
||||
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
|
||||
const mapRect = getDefaultMapRect(bounds);
|
||||
export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
|
||||
return {
|
||||
map: {
|
||||
id: MAP_WINDOW_ID,
|
||||
kind: "map",
|
||||
title: "供水管网地图",
|
||||
mode: "docked",
|
||||
rect: mapRect,
|
||||
restoreRect: mapRect,
|
||||
zIndex: 1,
|
||||
minimizedByLayout: false
|
||||
},
|
||||
analyses: [],
|
||||
activeWindowId: MAP_WINDOW_ID,
|
||||
nextZIndex: 2,
|
||||
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
|
||||
pendingArtifact: null,
|
||||
activeArtifact: null,
|
||||
requestedView: "map_split"
|
||||
};
|
||||
}
|
||||
|
||||
export function openAnalysisDocument(
|
||||
state: WorkspaceState,
|
||||
document: AnalysisDocument,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
|
||||
export function previewAnalysisArtifact(
|
||||
state: ArtifactWorkspaceState,
|
||||
artifact: AnalysisArtifact
|
||||
): ArtifactWorkspaceState {
|
||||
return { ...state, pendingArtifact: artifact };
|
||||
}
|
||||
|
||||
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,
|
||||
minimizedByLayout: false,
|
||||
document
|
||||
export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||
if (!state.pendingArtifact) return state;
|
||||
return {
|
||||
pendingArtifact: null,
|
||||
activeArtifact: state.pendingArtifact,
|
||||
requestedView: getPreferredArtifactView(state.pendingArtifact)
|
||||
};
|
||||
const opened: WorkspaceState = {
|
||||
...state,
|
||||
analyses: [...state.analyses, window],
|
||||
activeWindowId: window.id,
|
||||
nextZIndex: state.nextZIndex + 1,
|
||||
layout: {
|
||||
...state.layout,
|
||||
orderedWindowIds: uniqueIds([window.id, MAP_WINDOW_ID, ...state.layout.orderedWindowIds])
|
||||
}
|
||||
};
|
||||
|
||||
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
|
||||
return opened;
|
||||
}
|
||||
|
||||
export function closeAnalysisWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds = FALLBACK_BOUNDS
|
||||
): 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",
|
||||
minimizedByLayout: false,
|
||||
zIndex: state.nextZIndex
|
||||
},
|
||||
analyses,
|
||||
activeWindowId: MAP_WINDOW_ID,
|
||||
nextZIndex: state.nextZIndex + 1,
|
||||
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
|
||||
};
|
||||
}
|
||||
|
||||
const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
|
||||
const nextActive = state.activeWindowId === windowId
|
||||
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
|
||||
: state.activeWindowId;
|
||||
const closed: WorkspaceState = {
|
||||
...state,
|
||||
analyses,
|
||||
activeWindowId: nextActive ?? MAP_WINDOW_ID,
|
||||
layout: { ...state.layout, orderedWindowIds }
|
||||
};
|
||||
return state.layout.mode === "free" ? closed : arrangeWorkspaceLayout(closed, bounds);
|
||||
}
|
||||
|
||||
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
return updateWindowById(
|
||||
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
|
||||
windowId,
|
||||
(window) => ({ ...window, zIndex: state.nextZIndex })
|
||||
);
|
||||
}
|
||||
|
||||
export function minimizeWorkspaceWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds = FALLBACK_BOUNDS
|
||||
): WorkspaceState {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
const minimized = updateWindowById(state, windowId, (window) => ({
|
||||
...window,
|
||||
mode: "minimized",
|
||||
minimizedByLayout: false
|
||||
}));
|
||||
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
|
||||
}
|
||||
|
||||
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
return focusWorkspaceWindow(
|
||||
updateWindowById(state, windowId, (window) => ({
|
||||
...window,
|
||||
mode: "maximized",
|
||||
minimizedByLayout: false
|
||||
})),
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreWorkspaceWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
const window = getWindowById(state, windowId);
|
||||
if (!window) return state;
|
||||
|
||||
if (state.layout.mode !== "free") {
|
||||
const restored = updateWindowById(
|
||||
{
|
||||
...state,
|
||||
layout: {
|
||||
...state.layout,
|
||||
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
|
||||
}
|
||||
},
|
||||
windowId,
|
||||
(current) => ({ ...current, mode: "floating", minimizedByLayout: false })
|
||||
);
|
||||
return focusWorkspaceWindow(arrangeWorkspaceLayout(restored, bounds), windowId);
|
||||
}
|
||||
|
||||
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
|
||||
const rect = clampWorkspaceRect(window.restoreRect, bounds, kind);
|
||||
return focusWorkspaceWindow(
|
||||
updateWindowById(state, windowId, (current) => ({
|
||||
...current,
|
||||
mode: "floating",
|
||||
rect,
|
||||
minimizedByLayout: false
|
||||
})),
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
export function updateWorkspaceWindowRect(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
rect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, kind);
|
||||
return updateWindowById(
|
||||
{
|
||||
...state,
|
||||
layout: { ...state.layout, mode: "free" }
|
||||
},
|
||||
windowId,
|
||||
(window) => ({
|
||||
...window,
|
||||
mode: "floating",
|
||||
rect: nextRect,
|
||||
restoreRect: nextRect,
|
||||
minimizedByLayout: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function applyWorkspaceLayout(
|
||||
state: WorkspaceState,
|
||||
mode: WorkspaceLayoutMode,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (mode === "free") {
|
||||
return { ...state, layout: { ...state.layout, mode } };
|
||||
}
|
||||
|
||||
const orderedWindowIds = uniqueIds([
|
||||
state.activeWindowId,
|
||||
MAP_WINDOW_ID,
|
||||
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id),
|
||||
...state.layout.orderedWindowIds
|
||||
]);
|
||||
const restored = mapAllWorkspaceWindows(
|
||||
{
|
||||
...state,
|
||||
layout: { mode, orderedWindowIds }
|
||||
},
|
||||
(window) => ({ ...window, mode: "floating", minimizedByLayout: false })
|
||||
);
|
||||
return arrangeWorkspaceLayout(restored, bounds, true);
|
||||
}
|
||||
|
||||
export function setWorkspacePrimaryWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (!getWindowById(state, windowId) || state.layout.mode === "free") return state;
|
||||
const next = {
|
||||
...state,
|
||||
layout: {
|
||||
...state.layout,
|
||||
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
|
||||
}
|
||||
};
|
||||
return focusWorkspaceWindow(arrangeWorkspaceLayout(next, bounds), windowId);
|
||||
}
|
||||
|
||||
export function swapWorkspaceWindows(
|
||||
state: WorkspaceState,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
const source = getWindowById(state, sourceId);
|
||||
const target = getWindowById(state, targetId);
|
||||
if (!source || !target || sourceId === targetId || source.mode === "minimized" || target.mode === "minimized") {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.layout.mode !== "free") {
|
||||
const order = normalizeWindowOrder(state);
|
||||
const sourceIndex = order.indexOf(sourceId);
|
||||
const targetIndex = order.indexOf(targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return state;
|
||||
[order[sourceIndex], order[targetIndex]] = [order[targetIndex], order[sourceIndex]];
|
||||
const swapped = { ...state, layout: { ...state.layout, orderedWindowIds: order } };
|
||||
return focusWorkspaceWindow(arrangeWorkspaceLayout(swapped, bounds), sourceId);
|
||||
}
|
||||
|
||||
const swapped = updateWindowById(
|
||||
updateWindowById(state, sourceId, (window) => ({
|
||||
...window,
|
||||
mode: target.mode,
|
||||
rect: target.rect,
|
||||
restoreRect: target.restoreRect,
|
||||
minimizedByLayout: false
|
||||
})),
|
||||
targetId,
|
||||
(window) => ({
|
||||
...window,
|
||||
mode: source.mode,
|
||||
rect: source.rect,
|
||||
restoreRect: source.restoreRect,
|
||||
minimizedByLayout: false
|
||||
})
|
||||
);
|
||||
return focusWorkspaceWindow(swapped, sourceId);
|
||||
}
|
||||
|
||||
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
|
||||
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
|
||||
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||
if (!state.activeArtifact) return state;
|
||||
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")
|
||||
}))
|
||||
pendingArtifact: state.activeArtifact,
|
||||
activeArtifact: null
|
||||
};
|
||||
}
|
||||
|
||||
export function clampWorkspaceRect(
|
||||
rect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds,
|
||||
kind: "map" | "analysis"
|
||||
): WorkspaceRect {
|
||||
const minimumWidth = kind === "map" ? 320 : 360;
|
||||
const minimumHeight = kind === "map" ? 240 : 280;
|
||||
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
|
||||
};
|
||||
export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||
return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
|
||||
}
|
||||
|
||||
function arrangeWorkspaceLayout(
|
||||
state: WorkspaceState,
|
||||
bounds: WorkspaceBounds,
|
||||
restoreAll = false
|
||||
): WorkspaceState {
|
||||
if (state.layout.mode === "free") return state;
|
||||
|
||||
const orderedWindowIds = normalizeWindowOrder(state);
|
||||
const eligibleIds = orderedWindowIds.filter((id) => {
|
||||
const window = getWindowById(state, id);
|
||||
return window && (restoreAll || window.mode !== "minimized" || window.minimizedByLayout);
|
||||
});
|
||||
const visibleIds = selectVisibleWindowIds(eligibleIds, state.layout.mode);
|
||||
const visibleSet = new Set(visibleIds);
|
||||
const eligibleSet = new Set(eligibleIds);
|
||||
const rects = getLayoutRects(state.layout.mode, visibleIds.length, bounds);
|
||||
let next = mapAllWorkspaceWindows(state, (window) => {
|
||||
const visibleIndex = visibleIds.indexOf(window.id);
|
||||
if (visibleIndex >= 0) {
|
||||
const rect = rects[visibleIndex] ?? window.rect;
|
||||
return {
|
||||
...window,
|
||||
mode: !restoreAll && window.mode === "maximized" ? "maximized" : "floating",
|
||||
rect,
|
||||
restoreRect: rect,
|
||||
minimizedByLayout: false
|
||||
};
|
||||
}
|
||||
if (eligibleSet.has(window.id) && !visibleSet.has(window.id)) {
|
||||
return { ...window, mode: "minimized", minimizedByLayout: true };
|
||||
}
|
||||
return window;
|
||||
});
|
||||
const activeWindowId = visibleSet.has(next.activeWindowId)
|
||||
? next.activeWindowId
|
||||
: visibleIds[0] ?? next.activeWindowId;
|
||||
next = {
|
||||
...next,
|
||||
activeWindowId,
|
||||
layout: { ...next.layout, orderedWindowIds }
|
||||
};
|
||||
return next;
|
||||
export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||
return state.activeArtifact
|
||||
? { ...state, activeArtifact: null, requestedView: "map_split" }
|
||||
: state;
|
||||
}
|
||||
|
||||
function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) {
|
||||
const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length;
|
||||
const selected = ids.slice(0, capacity);
|
||||
if (
|
||||
(mode === "split" || mode === "primary") &&
|
||||
ids.includes(MAP_WINDOW_ID) &&
|
||||
!selected.includes(MAP_WINDOW_ID)
|
||||
) {
|
||||
selected[selected.length - 1] = MAP_WINDOW_ID;
|
||||
}
|
||||
return selected;
|
||||
export function requestArtifactView(
|
||||
state: ArtifactWorkspaceState,
|
||||
view: ArtifactRequestedView
|
||||
): ArtifactWorkspaceState {
|
||||
if (!state.activeArtifact) return state;
|
||||
return { ...state, requestedView: view };
|
||||
}
|
||||
|
||||
function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] {
|
||||
if (count === 0) return [];
|
||||
const width = Math.max(1, bounds.width - LAYOUT_INSET * 2);
|
||||
const height = Math.max(1, bounds.height - LAYOUT_INSET * 2);
|
||||
|
||||
if (mode === "primary") {
|
||||
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
|
||||
const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68);
|
||||
const secondaryWidth = width - LAYOUT_GAP - primaryWidth;
|
||||
const secondaryHeight = count === 2 ? height : Math.floor((height - LAYOUT_GAP) / 2);
|
||||
return [
|
||||
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: primaryWidth, height },
|
||||
{ x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP, y: LAYOUT_INSET, width: secondaryWidth, height: secondaryHeight },
|
||||
...(count === 3
|
||||
? [{
|
||||
x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP,
|
||||
y: LAYOUT_INSET + secondaryHeight + LAYOUT_GAP,
|
||||
width: secondaryWidth,
|
||||
height: height - secondaryHeight - LAYOUT_GAP
|
||||
}]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
if (mode === "split") {
|
||||
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
|
||||
const firstWidth = Math.floor((width - LAYOUT_GAP) / 2);
|
||||
return [
|
||||
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: firstWidth, height },
|
||||
{
|
||||
x: LAYOUT_INSET + firstWidth + LAYOUT_GAP,
|
||||
y: LAYOUT_INSET,
|
||||
width: width - firstWidth - LAYOUT_GAP,
|
||||
height
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (mode === "cascade") {
|
||||
const windowWidth = Math.max(280, Math.floor(width * 0.78));
|
||||
const windowHeight = Math.max(240, Math.floor(height * 0.78));
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
x: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.width - windowWidth - LAYOUT_INSET)),
|
||||
y: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.height - windowHeight - LAYOUT_INSET)),
|
||||
width: Math.min(windowWidth, width),
|
||||
height: Math.min(windowHeight, height)
|
||||
}));
|
||||
}
|
||||
|
||||
const columns = count === 1 ? 1 : count <= 4 ? 2 : 3;
|
||||
const rows = Math.ceil(count / columns);
|
||||
const cellWidth = Math.floor((width - LAYOUT_GAP * (columns - 1)) / columns);
|
||||
const cellHeight = Math.floor((height - LAYOUT_GAP * (rows - 1)) / rows);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
return {
|
||||
x: LAYOUT_INSET + column * (cellWidth + LAYOUT_GAP),
|
||||
y: LAYOUT_INSET + row * (cellHeight + LAYOUT_GAP),
|
||||
width: column === columns - 1 ? width - column * (cellWidth + LAYOUT_GAP) : cellWidth,
|
||||
height: row === rows - 1 ? height - row * (cellHeight + LAYOUT_GAP) : cellHeight
|
||||
};
|
||||
});
|
||||
export function resolveWorkbenchSurfaceMode(
|
||||
state: ArtifactWorkspaceState,
|
||||
viewportWidth: number
|
||||
): WorkbenchSurfaceMode {
|
||||
const artifact = state.activeArtifact;
|
||||
if (!artifact) return "default";
|
||||
if (state.requestedView === "map_only") return "map_only";
|
||||
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
|
||||
return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
|
||||
}
|
||||
|
||||
function normalizeWindowOrder(state: WorkspaceState) {
|
||||
const ids = new Set(allWorkspaceWindows(state).map((window) => window.id));
|
||||
return uniqueIds([
|
||||
...state.layout.orderedWindowIds,
|
||||
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id)
|
||||
]).filter((id) => ids.has(id));
|
||||
}
|
||||
|
||||
function getWindowById(state: WorkspaceState, windowId: string): MapWorkspaceWindow | AnalysisWorkspaceWindow | undefined {
|
||||
return windowId === MAP_WINDOW_ID ? state.map : state.analyses.find((window) => window.id === windowId);
|
||||
}
|
||||
|
||||
function updateWindowById(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
|
||||
): WorkspaceState {
|
||||
if (windowId === MAP_WINDOW_ID) {
|
||||
return { ...state, map: update(state.map) };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
analyses: state.analyses.map((window) => window.id === windowId ? update(window) : window)
|
||||
};
|
||||
}
|
||||
|
||||
function mapAllWorkspaceWindows(
|
||||
state: WorkspaceState,
|
||||
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
|
||||
): WorkspaceState {
|
||||
return {
|
||||
...state,
|
||||
map: update(state.map),
|
||||
analyses: state.analyses.map((window) => update(window))
|
||||
};
|
||||
}
|
||||
|
||||
function allWorkspaceWindows(state: WorkspaceState) {
|
||||
return [state.map, ...state.analyses] as Array<MapWorkspaceWindow | AnalysisWorkspaceWindow>;
|
||||
}
|
||||
|
||||
function uniqueIds(ids: string[]) {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
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);
|
||||
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
|
||||
return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import type {
|
||||
WorkspaceBounds,
|
||||
WorkspaceLayoutMode,
|
||||
WorkspaceRect
|
||||
} from "./workspace-model";
|
||||
|
||||
const HORIZONTAL_INSET = 28;
|
||||
const TOP_INSET = 48;
|
||||
const LAYOUT_RAIL_SPACE = 112;
|
||||
const PREVIEW_GAP = 20;
|
||||
const LABEL_SPACE = 28;
|
||||
|
||||
export const WORKSPACE_LAYOUT_LABELS: Record<WorkspaceLayoutMode, string> = {
|
||||
free: "自由排列",
|
||||
primary: "主次",
|
||||
split: "双栏",
|
||||
grid: "网格",
|
||||
cascade: "层叠"
|
||||
};
|
||||
|
||||
export function getTaskViewPreviewRects(
|
||||
count: number,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceRect[] {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const columns = getTaskViewColumnCount(count);
|
||||
const rows = Math.ceil(count / columns);
|
||||
const availableWidth = Math.max(1, bounds.width - HORIZONTAL_INSET * 2);
|
||||
const availableHeight = Math.max(
|
||||
1,
|
||||
bounds.height - TOP_INSET - LAYOUT_RAIL_SPACE
|
||||
);
|
||||
const cellWidth = Math.max(
|
||||
1,
|
||||
(availableWidth - PREVIEW_GAP * (columns - 1)) / columns
|
||||
);
|
||||
const cellHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - PREVIEW_GAP * (rows - 1)) / rows
|
||||
);
|
||||
const previewHeight = Math.max(1, cellHeight - LABEL_SPACE);
|
||||
const occupiedColumns = Math.min(count, columns);
|
||||
const firstRowOffset = (availableWidth
|
||||
- occupiedColumns * cellWidth
|
||||
- Math.max(0, occupiedColumns - 1) * PREVIEW_GAP) / 2;
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const itemsInRow = Math.min(columns, count - row * columns);
|
||||
const rowOffset = row === 0
|
||||
? firstRowOffset
|
||||
: (availableWidth
|
||||
- itemsInRow * cellWidth
|
||||
- Math.max(0, itemsInRow - 1) * PREVIEW_GAP) / 2;
|
||||
|
||||
return {
|
||||
x: HORIZONTAL_INSET + rowOffset + column * (cellWidth + PREVIEW_GAP),
|
||||
y: TOP_INSET + row * (cellHeight + PREVIEW_GAP),
|
||||
width: cellWidth,
|
||||
height: previewHeight
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskViewColumnCount(count: number) {
|
||||
return count <= 1 ? 1 : count <= 4 ? 2 : 3;
|
||||
}
|
||||
|
||||
export function getTaskViewSourceRect(
|
||||
mode: "docked" | "floating" | "maximized" | "minimized",
|
||||
rect: WorkspaceRect,
|
||||
restoreRect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceRect {
|
||||
if (mode === "docked" || mode === "maximized") {
|
||||
return { x: 0, y: 0, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
return mode === "minimized" ? restoreRect : rect;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTaskViewPreviewRects,
|
||||
getTaskViewSourceRect
|
||||
} from "./workspace-task-view-layout";
|
||||
|
||||
const bounds = { width: 1440, height: 804 };
|
||||
|
||||
describe("workspace task view geometry", () => {
|
||||
it.each([1, 2, 4, 7])("keeps %i previews inside the task view area", (count) => {
|
||||
const rects = getTaskViewPreviewRects(count, bounds);
|
||||
|
||||
expect(rects).toHaveLength(count);
|
||||
for (const rect of rects) {
|
||||
expect(rect.x).toBeGreaterThanOrEqual(28);
|
||||
expect(rect.y).toBeGreaterThanOrEqual(48);
|
||||
expect(rect.x + rect.width).toBeLessThanOrEqual(bounds.width - 28);
|
||||
expect(rect.y + rect.height).toBeLessThanOrEqual(bounds.height - 112);
|
||||
expect(rect.width).toBeGreaterThan(0);
|
||||
expect(rect.height).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("centers an incomplete last row", () => {
|
||||
const rects = getTaskViewPreviewRects(7, bounds);
|
||||
const last = rects.at(-1);
|
||||
|
||||
expect(last).toBeDefined();
|
||||
expect(Math.abs((last!.x + last!.width / 2) - bounds.width / 2)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("uses the visible workspace for filled windows and restore geometry for minimized windows", () => {
|
||||
const rect = { x: 80, y: 60, width: 720, height: 520 };
|
||||
const restoreRect = { x: 120, y: 90, width: 660, height: 480 };
|
||||
|
||||
expect(getTaskViewSourceRect("docked", rect, restoreRect, bounds)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
});
|
||||
expect(getTaskViewSourceRect("minimized", rect, restoreRect, bounds)).toBe(restoreRect);
|
||||
expect(getTaskViewSourceRect("floating", rect, restoreRect, bounds)).toBe(rect);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Map as MapIcon, PanelsTopLeft, Sparkles } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type {
|
||||
WorkspaceLayoutMode,
|
||||
WorkspaceRect,
|
||||
WorkspaceState
|
||||
} from "./workspace-model";
|
||||
import { WORKSPACE_LAYOUT_LABELS } from "./workspace-task-view-layout";
|
||||
|
||||
const SNAP_LAYOUT_OPTIONS: Array<{
|
||||
mode: WorkspaceLayoutMode;
|
||||
description: string;
|
||||
minimumWindows: number;
|
||||
}> = [
|
||||
{ mode: "primary", description: "一个主窗口与辅助窗口", minimumWindows: 2 },
|
||||
{ mode: "split", description: "两个窗口等宽并排", minimumWindows: 2 },
|
||||
{ mode: "grid", description: "全部窗口自适应平铺", minimumWindows: 2 },
|
||||
{ mode: "cascade", description: "全部窗口依次层叠", minimumWindows: 2 },
|
||||
{ mode: "free", description: "保留窗口的自由位置", minimumWindows: 1 }
|
||||
];
|
||||
|
||||
type TaskViewWindow = WorkspaceState["map"] | WorkspaceState["analyses"][number];
|
||||
|
||||
export function WorkspaceTaskView({
|
||||
windows,
|
||||
previewRects,
|
||||
focusedWindowId,
|
||||
layoutMode,
|
||||
onClose,
|
||||
onFocusWindow,
|
||||
onSelectWindow,
|
||||
onWindowKeyDown,
|
||||
onLayoutChange
|
||||
}: {
|
||||
windows: TaskViewWindow[];
|
||||
previewRects: WorkspaceRect[];
|
||||
focusedWindowId: string;
|
||||
layoutMode: WorkspaceLayoutMode;
|
||||
onClose: () => void;
|
||||
onFocusWindow: (windowId: string) => void;
|
||||
onSelectWindow: (windowId: string) => void;
|
||||
onWindowKeyDown: (event: KeyboardEvent<HTMLButtonElement>, index: number) => void;
|
||||
onLayoutChange: (mode: WorkspaceLayoutMode) => void;
|
||||
}) {
|
||||
function handleDialogKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")]
|
||||
.filter((control) => control.tabIndex >= 0);
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-[2147482000] cursor-default bg-[#e7edf3]/96"
|
||||
onPointerDown={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="任务视图"
|
||||
className="pointer-events-none absolute inset-0 z-[2147482200]"
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
<div className="absolute left-7 top-4 flex items-center gap-2 text-slate-700">
|
||||
<PanelsTopLeft size={17} aria-hidden="true" />
|
||||
<span className="text-sm font-semibold">任务视图</span>
|
||||
<span className="text-xs text-slate-500 tabular-nums">{windows.length} 个窗口</span>
|
||||
</div>
|
||||
|
||||
{windows.map((window, index) => {
|
||||
const rect = previewRects[index];
|
||||
if (!rect) return null;
|
||||
const focused = focusedWindowId === window.id;
|
||||
const minimized = window.mode === "minimized";
|
||||
return (
|
||||
<button
|
||||
key={window.id}
|
||||
type="button"
|
||||
data-task-view-window-id={window.id}
|
||||
aria-label={`打开${window.title}并铺满主视图${minimized ? ",当前已最小化" : ""}`}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute rounded-md transition-[background-color,box-shadow,scale] duration-150 active:scale-[0.99] focus-visible:outline-hidden motion-reduce:transition-none",
|
||||
focused
|
||||
? "bg-blue-500/8 shadow-[0_0_0_2px_rgba(37,99,235,0.92)]"
|
||||
: "[@media(hover:hover)]:hover:bg-white/45 [@media(hover:hover)]:hover:shadow-[0_0_0_2px_rgba(148,163,184,0.8)]"
|
||||
)}
|
||||
style={{
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height + 28
|
||||
}}
|
||||
onClick={() => onSelectWindow(window.id)}
|
||||
onFocus={() => onFocusWindow(window.id)}
|
||||
onPointerEnter={() => onFocusWindow(window.id)}
|
||||
onKeyDown={(event) => onWindowKeyDown(event, index)}
|
||||
>
|
||||
<span className="absolute bottom-0 left-1/2 flex max-w-[calc(100%-0.75rem)] -translate-x-1/2 items-center gap-1.5 rounded-md bg-slate-950/88 px-2.5 py-1 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(15,23,42,0.2)]">
|
||||
{window.kind === "map" ? <MapIcon size={13} aria-hidden="true" /> : <Sparkles size={13} aria-hidden="true" />}
|
||||
<span className="truncate">{window.title}</span>
|
||||
{minimized ? <span className="shrink-0 text-[10px] text-slate-300">已最小化</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 flex -translate-x-1/2 items-end gap-1.5 rounded-xl bg-white px-3 py-2.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)]">
|
||||
<span className="mr-1 self-center text-xs font-semibold text-slate-500">贴靠布局</span>
|
||||
{SNAP_LAYOUT_OPTIONS.map((option) => {
|
||||
const disabled = windows.length < option.minimumWindows;
|
||||
const active = layoutMode === option.mode;
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-label={`${WORKSPACE_LAYOUT_LABELS[option.mode]}:${option.description}`}
|
||||
aria-pressed={active}
|
||||
title={option.description}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group/layout flex h-16 w-[76px] flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-semibold transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-40",
|
||||
active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
onClick={() => onLayoutChange(option.mode)}
|
||||
>
|
||||
<SnapLayoutDiagram mode={option.mode} active={active} />
|
||||
{WORKSPACE_LAYOUT_LABELS[option.mode]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapLayoutDiagram({ mode, active }: { mode: WorkspaceLayoutMode; active: boolean }) {
|
||||
const cellClass = active ? "bg-blue-600" : "bg-slate-400 group-hover/layout:bg-slate-500";
|
||||
|
||||
if (mode === "primary") {
|
||||
return (
|
||||
<span className="grid h-7 w-11 grid-cols-[2fr_1fr] gap-0.5" aria-hidden="true">
|
||||
<span className={cn("rounded-[2px]", cellClass)} />
|
||||
<span className="grid grid-rows-2 gap-0.5">
|
||||
<span className={cn("rounded-[2px]", cellClass)} />
|
||||
<span className={cn("rounded-[2px]", cellClass)} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "split") {
|
||||
return (
|
||||
<span className="grid h-7 w-11 grid-cols-2 gap-0.5" aria-hidden="true">
|
||||
<span className={cn("rounded-[2px]", cellClass)} />
|
||||
<span className={cn("rounded-[2px]", cellClass)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "grid") {
|
||||
return (
|
||||
<span className="grid h-7 w-11 grid-cols-2 grid-rows-2 gap-0.5" aria-hidden="true">
|
||||
{Array.from({ length: 4 }, (_, index) => <span key={index} className={cn("rounded-[2px]", cellClass)} />)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "cascade") {
|
||||
return (
|
||||
<span className="relative h-7 w-11" aria-hidden="true">
|
||||
<span className={cn("absolute left-0 top-0 h-5 w-8 rounded-[2px] opacity-50", cellClass)} />
|
||||
<span className={cn("absolute bottom-0 right-0 h-5 w-8 rounded-[2px]", cellClass)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative h-7 w-11" aria-hidden="true">
|
||||
<span className={cn("absolute left-0 top-1 h-4 w-6 rounded-[2px] opacity-60", cellClass)} />
|
||||
<span className={cn("absolute bottom-0 right-0 h-4 w-7 rounded-[2px]", cellClass)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import {
|
||||
Grip,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
||||
|
||||
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export type WorkspaceTaskViewPreview = {
|
||||
sourceRect: WorkspaceRect;
|
||||
targetRect: WorkspaceRect;
|
||||
index: number;
|
||||
};
|
||||
|
||||
type WorkspaceWindowProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: WorkspaceWindowMode;
|
||||
rect: WorkspaceRect;
|
||||
zIndex: number;
|
||||
active: boolean;
|
||||
kind: "map" | "analysis";
|
||||
swapTarget?: boolean;
|
||||
taskViewPreview?: WorkspaceTaskViewPreview;
|
||||
children: ReactNode;
|
||||
onFocus: () => void;
|
||||
onRectChange: (rect: WorkspaceRect) => void;
|
||||
onMinimize: () => void;
|
||||
onMaximize: () => void;
|
||||
onRestore: () => void;
|
||||
onClose?: () => void;
|
||||
onMoveStart?: (rect: WorkspaceRect) => void;
|
||||
onMovePreview?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
|
||||
onMoveEnd?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
|
||||
onMoveCancel?: () => void;
|
||||
};
|
||||
|
||||
export function WorkspaceWindow({
|
||||
id,
|
||||
title,
|
||||
mode,
|
||||
rect,
|
||||
zIndex,
|
||||
active,
|
||||
kind,
|
||||
swapTarget = false,
|
||||
taskViewPreview,
|
||||
children,
|
||||
onFocus,
|
||||
onRectChange,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onRestore,
|
||||
onClose,
|
||||
onMoveStart,
|
||||
onMovePreview,
|
||||
onMoveEnd,
|
||||
onMoveCancel
|
||||
}: WorkspaceWindowProps) {
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const cleanupPointerActionRef = useRef<(() => void) | null>(null);
|
||||
const [dragRect, setDragRect] = useState<WorkspaceRect | null>(null);
|
||||
const rectRef = useRef(rect);
|
||||
rectRef.current = rect;
|
||||
|
||||
const floating = mode === "floating";
|
||||
const displayedRect = dragRect ?? rect;
|
||||
const displayedZIndex = taskViewPreview
|
||||
? 2_147_482_100 + taskViewPreview.index
|
||||
: swapTarget
|
||||
? 2_147_483_000
|
||||
: zIndex;
|
||||
const style: CSSProperties = taskViewPreview
|
||||
? getTaskViewStyle(taskViewPreview, displayedZIndex)
|
||||
: dragRect
|
||||
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
|
||||
: mode === "docked"
|
||||
? { inset: 0, zIndex: displayedZIndex }
|
||||
: mode === "maximized"
|
||||
? { inset: 0, zIndex: displayedZIndex }
|
||||
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
|
||||
|
||||
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
|
||||
|
||||
const beginPointerAction = (
|
||||
event: ReactPointerEvent,
|
||||
action: "move" | ResizeDirection
|
||||
) => {
|
||||
const moving = action === "move";
|
||||
if (
|
||||
(!moving && !floating)
|
||||
|| (moving && mode !== "floating" && mode !== "docked" && mode !== "maximized")
|
||||
|| event.button !== 0
|
||||
) return;
|
||||
if (
|
||||
event.target instanceof Element
|
||||
&& (
|
||||
(!moving && event.target.closest("button"))
|
||||
|| (moving && event.target.closest("[data-window-operation]"))
|
||||
)
|
||||
) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFocus();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startRect = rectRef.current;
|
||||
const sectionBounds = sectionRef.current?.getBoundingClientRect();
|
||||
const workspaceBounds = sectionRef.current?.parentElement?.getBoundingClientRect();
|
||||
const pointerOffsetX = sectionBounds ? event.clientX - sectionBounds.left : 24;
|
||||
const pointerOffsetY = sectionBounds ? event.clientY - sectionBounds.top : 22;
|
||||
let moved = false;
|
||||
if (moving) onMoveStart?.(startRect);
|
||||
|
||||
const handleMove = (pointerEvent: PointerEvent) => {
|
||||
const deltaX = pointerEvent.clientX - startX;
|
||||
const deltaY = pointerEvent.clientY - startY;
|
||||
if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
|
||||
moved = true;
|
||||
if (moving) {
|
||||
const nextRect = mode !== "floating" && workspaceBounds
|
||||
? {
|
||||
...startRect,
|
||||
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
|
||||
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
|
||||
}
|
||||
: resizeRect(startRect, "move", deltaX, deltaY);
|
||||
setDragRect(nextRect);
|
||||
onMovePreview?.(nextRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
|
||||
return;
|
||||
}
|
||||
onRectChange(resizeRect(startRect, action, deltaX, deltaY));
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleCancel);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
cleanupPointerActionRef.current = null;
|
||||
};
|
||||
const handleUp = (pointerEvent: PointerEvent) => {
|
||||
cleanup();
|
||||
if (!moving || !moved) {
|
||||
if (moving) onMoveCancel?.();
|
||||
setDragRect(null);
|
||||
return;
|
||||
}
|
||||
const finalRect = mode !== "floating" && workspaceBounds
|
||||
? {
|
||||
...startRect,
|
||||
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
|
||||
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
|
||||
}
|
||||
: resizeRect(startRect, "move", pointerEvent.clientX - startX, pointerEvent.clientY - startY);
|
||||
setDragRect(null);
|
||||
onMoveEnd?.(finalRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
|
||||
};
|
||||
const handleCancel = () => {
|
||||
cleanup();
|
||||
setDragRect(null);
|
||||
if (moving) onMoveCancel?.();
|
||||
};
|
||||
const handleKeyDown = (keyboardEvent: KeyboardEvent) => {
|
||||
if (keyboardEvent.key !== "Escape") return;
|
||||
keyboardEvent.preventDefault();
|
||||
handleCancel();
|
||||
};
|
||||
cleanupPointerActionRef.current?.();
|
||||
cleanupPointerActionRef.current = cleanup;
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp, { once: true });
|
||||
window.addEventListener("pointercancel", handleCancel, { once: true });
|
||||
if (moving) window.addEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
const handleOperationKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
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 }
|
||||
);
|
||||
};
|
||||
|
||||
const handleWindowPointerDownCapture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
onFocus();
|
||||
if (event.altKey) beginPointerAction(event, "move");
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id={id}
|
||||
data-workspace-window-id={id}
|
||||
aria-label={title}
|
||||
aria-hidden={taskViewPreview ? true : undefined}
|
||||
inert={taskViewPreview ? true : undefined}
|
||||
className={cn(
|
||||
"group/workspace-window absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[transform,box-shadow,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
|
||||
taskViewPreview ? "pointer-events-none flex" : "pointer-events-auto",
|
||||
taskViewPreview
|
||||
? kind === "analysis" ? "hidden lg:flex" : "flex"
|
||||
: mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
||||
(taskViewPreview || mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
|
||||
)}
|
||||
style={style}
|
||||
onPointerDownCapture={handleWindowPointerDownCapture}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
{active && !taskViewPreview ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-window-operation
|
||||
aria-label={`打开${title}窗口操作`}
|
||||
title={`${title}窗口操作`}
|
||||
className="absolute left-2 top-2 z-40 hidden h-10 w-10 place-items-center rounded-md bg-slate-950/84 text-white opacity-0 shadow-[0_6px_18px_rgba(15,23,42,0.24)] transition-[opacity,background-color,scale] duration-150 [@media(hover:hover)]:group-hover/workspace-window:grid [@media(hover:hover)]:group-hover/workspace-window:opacity-100 hover:bg-slate-950 active:scale-[0.96] focus:grid focus:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 data-[state=open]:grid data-[state=open]:opacity-100 lg:grid"
|
||||
onKeyDown={handleOperationKeyDown}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grip size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
data-window-operation
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-52 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.2)] motion-reduce:animate-none"
|
||||
>
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMinimize}>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
最小化
|
||||
</DropdownMenuItem>
|
||||
{mode === "maximized" || mode === "docked" ? (
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onRestore}>
|
||||
<Minimize2 size={15} aria-hidden="true" />
|
||||
恢复为浮动窗口
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMaximize}>
|
||||
<Maximize2 size={15} aria-hidden="true" />
|
||||
最大化
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onClose ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1 bg-slate-200" />
|
||||
<DropdownMenuItem
|
||||
className="min-h-10 rounded-md text-xs font-semibold text-rose-700 focus:bg-rose-50 focus:text-rose-800"
|
||||
onSelect={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
关闭并销毁
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{swapTarget && !taskViewPreview ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-2 z-30 grid place-items-center rounded-md bg-blue-50/90 opacity-100 ring-2 ring-inset ring-blue-600 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
>
|
||||
<span className="rounded-md bg-blue-700 px-3 py-2 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(29,78,216,0.28)]">
|
||||
释放以交换
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{floating && !taskViewPreview ? 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 getTaskViewStyle(
|
||||
preview: WorkspaceTaskViewPreview,
|
||||
zIndex: number
|
||||
): CSSProperties {
|
||||
const { sourceRect, targetRect } = preview;
|
||||
const scale = Math.min(
|
||||
targetRect.width / sourceRect.width,
|
||||
targetRect.height / sourceRect.height
|
||||
);
|
||||
const renderedWidth = sourceRect.width * scale;
|
||||
const renderedHeight = sourceRect.height * scale;
|
||||
const targetLeft = targetRect.x + (targetRect.width - renderedWidth) / 2;
|
||||
const targetTop = targetRect.y + (targetRect.height - renderedHeight) / 2;
|
||||
|
||||
return {
|
||||
left: sourceRect.x,
|
||||
top: sourceRect.y,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height,
|
||||
transform: `translate(${targetLeft - sourceRect.x}px, ${targetTop - sourceRect.y}px) scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
zIndex
|
||||
};
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
+5
-39
@@ -899,49 +899,15 @@ 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 {
|
||||
.workbench-agent-dock .agent-panel-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #f7f9fb;
|
||||
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
||||
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;
|
||||
|
||||
@@ -184,11 +184,12 @@ test("Main Frame navigation respects forced color mode", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
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");
|
||||
await expect(workspace).toBeVisible();
|
||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
||||
|
||||
@@ -1,52 +1,45 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("unified workbench navigation collapses and restores the main workspace", async ({ page }) => {
|
||||
test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
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 expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
|
||||
expect(expandedWidth).toBeGreaterThan(400);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||
|
||||
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.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
|
||||
|
||||
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);
|
||||
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||
});
|
||||
|
||||
test("unified navigation switches between Agent and condition panes", async ({ page }) => {
|
||||
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
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();
|
||||
|
||||
await mainFrame.getByRole("button", { name: "Agent", exact: true }).click();
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||
|
||||
const topBarConditionToggle = page
|
||||
const conditionToggle = 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 conditionToggle.click();
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(conditionPanel).toBeVisible();
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
|
||||
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||
await conditionToggle.click();
|
||||
await expect(conditionPanel).toBeHidden();
|
||||
});
|
||||
|
||||
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
@@ -111,6 +112,7 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
|
||||
|
||||
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
@@ -199,6 +202,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
||||
|
||||
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const reference = page
|
||||
.getByRole("region", { name: "工况任务", exact: true })
|
||||
|
||||
@@ -21,6 +21,7 @@ test("opening Agent and condition panels does not move the map camera", async ({
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(conditionPanel).toHaveCSS("width", "880px");
|
||||
|
||||
@@ -192,7 +192,6 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
||||
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 命令面板"]');
|
||||
await expect(navigation).toHaveCSS("width", "368px");
|
||||
@@ -201,20 +200,16 @@ async function expectDesktopMainFrameGeometry(page: Page) {
|
||||
|
||||
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();
|
||||
expect(navigationBox).not.toBeNull();
|
||||
expect(workspaceBox).not.toBeNull();
|
||||
expect(taskbarBox).not.toBeNull();
|
||||
expect(mapBox).not.toBeNull();
|
||||
expect(agentBox).not.toBeNull();
|
||||
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);
|
||||
|
||||
@@ -1,250 +1,132 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("temporary Agent analysis windows float above the persistent map workspace", async ({ page }) => {
|
||||
test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
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();
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const map = page.locator("#workspace-map");
|
||||
|
||||
await expect(frame).toBeVisible();
|
||||
await expect(mapWindow).toBeVisible();
|
||||
await expect(testButton).toBeVisible();
|
||||
await expect(page.getByLabel("工作台导航")).toHaveCount(0);
|
||||
await expect(workspace).toHaveAttribute("data-layout-mode", "default");
|
||||
await expect(map).toBeVisible();
|
||||
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||
await expect(mapWindow.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||
await expect(page.locator(".workspace-window-titlebar")).toHaveCount(0);
|
||||
|
||||
await testButton.click();
|
||||
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible();
|
||||
await expect(mapWindow).toBeVisible();
|
||||
|
||||
await testButton.click();
|
||||
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible();
|
||||
|
||||
await openWindowOperationMenu(page, "供水服务分区平衡分析");
|
||||
await page.getByRole("menuitem", { 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 openWindowOperationMenu(page, "供水服务分区平衡分析");
|
||||
await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
|
||||
await openWindowOperationMenu(page, "北辰分区压力异常诊断");
|
||||
await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
|
||||
await expect(mapWindow).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible();
|
||||
await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "任务视图" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the map document floats and minimizes to its taskbar entry", async ({ page }) => {
|
||||
test("a generated Artifact stays in peek until explicitly opened", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||
const peek = page.getByRole("complementary", { name: "Agent 成果预览" });
|
||||
|
||||
await expect(peek).toBeVisible();
|
||||
await expect(peek).toContainText("北辰分区压力异常诊断");
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toHaveCount(0);
|
||||
|
||||
await peek.getByRole("button", { name: "打开成果" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
await expect(page.locator("#artifact-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toBeVisible();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Agent expands over an active Artifact without resizing the main workspace", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openFirstArtifact(page);
|
||||
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const mapWindow = page.locator("#workspace-map");
|
||||
const dockedBox = await workspace.boundingBox();
|
||||
expect(dockedBox).not.toBeNull();
|
||||
|
||||
await openWindowOperationMenu(page, "供水管网地图");
|
||||
await page.getByRole("menuitem", { 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(mapWindow).toHaveCSS("border-radius", "0px");
|
||||
const floatingClassName = await mapWindow.getAttribute("class");
|
||||
expect(floatingClassName).not.toContain("ring-1");
|
||||
expect(await mapWindow.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none");
|
||||
await expect(page.getByRole("button", { name: "打开供水管网地图窗口操作" })).toBeAttached();
|
||||
|
||||
await openWindowOperationMenu(page, "供水管网地图");
|
||||
await page.getByRole("menuitem", { 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: "打开供水管网地图窗口操作" })).toBeAttached();
|
||||
});
|
||||
|
||||
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("task view shows every window and fills the workspace with the selected one", 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 openWindowOperationMenu(page, "北辰分区压力异常诊断");
|
||||
await page.getByRole("menuitem", { name: "最小化" }).click();
|
||||
await page.getByRole("button", { name: "任务视图" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "任务视图" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /打开供水管网地图并铺满主视图/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /打开北辰分区压力异常诊断并铺满主视图,当前已最小化/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
|
||||
const workspaceBox = await page.locator("#main-workspace").boundingBox();
|
||||
const selectedBox = await page.locator("section[aria-label='供水服务分区平衡分析']").boundingBox();
|
||||
expect(workspaceBox).not.toBeNull();
|
||||
expect(selectedBox).not.toBeNull();
|
||||
expect(Math.abs(selectedBox!.width - workspaceBox!.width)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(selectedBox!.height - workspaceBox!.height)).toBeLessThanOrEqual(2);
|
||||
|
||||
await page.getByRole("button", { name: "任务视图" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "任务视图" })).toBeFocused();
|
||||
});
|
||||
|
||||
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")).toBeVisible();
|
||||
});
|
||||
|
||||
test("layout presets arrange transient windows and keep the map visible", 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 applyTaskViewLayout(page, "主次");
|
||||
|
||||
const workspace = page.locator("#main-workspace");
|
||||
await expect(workspace).toHaveAttribute("data-layout-mode", "primary");
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toBeVisible();
|
||||
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']")).toBeHidden();
|
||||
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toHaveCSS("border-radius", "0px");
|
||||
|
||||
const workspaceBox = await workspace.boundingBox();
|
||||
const primaryBox = await page.locator("section[aria-label='低压事件调度处置流程']").boundingBox();
|
||||
expect(workspaceBox).not.toBeNull();
|
||||
expect(primaryBox).not.toBeNull();
|
||||
expect(primaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
|
||||
|
||||
await testButton.click();
|
||||
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']").last()).toBeVisible();
|
||||
const newPrimaryBox = await page.locator("section[aria-label='北辰分区压力异常诊断']").last().boundingBox();
|
||||
expect(newPrimaryBox).not.toBeNull();
|
||||
expect(newPrimaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
|
||||
});
|
||||
|
||||
test("dragging a small window onto the primary window swaps their complete slots", async ({ page }) => {
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
||||
await applyTaskViewLayout(page, "主次");
|
||||
|
||||
const mapWindow = page.locator("#workspace-map");
|
||||
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
||||
const mapBefore = await mapWindow.boundingBox();
|
||||
const analysisBefore = await analysisWindow.boundingBox();
|
||||
expect(mapBefore).not.toBeNull();
|
||||
expect(analysisBefore).not.toBeNull();
|
||||
expect(mapBefore!.width).toBeLessThan(analysisBefore!.width);
|
||||
|
||||
await page.mouse.move(mapBefore!.x + 72, mapBefore!.y + 22);
|
||||
await page.keyboard.down("Alt");
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
analysisBefore!.x + analysisBefore!.width / 2,
|
||||
analysisBefore!.y + analysisBefore!.height / 2,
|
||||
{ steps: 10 }
|
||||
);
|
||||
await expect(page.getByText("释放以交换")).toBeVisible();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up("Alt");
|
||||
|
||||
const mapAfter = await mapWindow.boundingBox();
|
||||
const analysisAfter = await analysisWindow.boundingBox();
|
||||
expect(mapAfter).not.toBeNull();
|
||||
expect(analysisAfter).not.toBeNull();
|
||||
expect(mapAfter!.width).toBeGreaterThan(analysisAfter!.width);
|
||||
expect(Math.abs(mapAfter!.x - analysisBefore!.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(analysisAfter!.x - mapBefore!.x)).toBeLessThanOrEqual(2);
|
||||
await expect(page.getByText("供水管网地图与北辰分区压力异常诊断已交换位置")).toBeAttached();
|
||||
});
|
||||
|
||||
test("dropping on blank workspace exits auto layout and Escape cancels a drag", async ({ page }) => {
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
||||
await applyTaskViewLayout(page, "双栏");
|
||||
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
||||
const before = await analysisWindow.boundingBox();
|
||||
const workspaceBox = await workspace.boundingBox();
|
||||
const before = await workspace.boundingBox();
|
||||
expect(before).not.toBeNull();
|
||||
expect(workspaceBox).not.toBeNull();
|
||||
|
||||
await page.mouse.move(before!.x + 80, before!.y + 22);
|
||||
await page.keyboard.down("Alt");
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.up("Alt");
|
||||
await expect(workspace).toHaveAttribute("data-layout-mode", "split");
|
||||
const afterCancel = await analysisWindow.boundingBox();
|
||||
expect(Math.abs(afterCancel!.x - before!.x)).toBeLessThanOrEqual(2);
|
||||
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||
const after = await workspace.boundingBox();
|
||||
|
||||
await page.mouse.move(before!.x + 80, before!.y + 22);
|
||||
await page.keyboard.down("Alt");
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up("Alt");
|
||||
await expect(workspace).toHaveAttribute("data-layout-mode", "free");
|
||||
await expect(page.getByText("北辰分区压力异常诊断已转为自由排列")).toBeAttached();
|
||||
expect(after).not.toBeNull();
|
||||
expect(after!.x).toBe(before!.x);
|
||||
expect(after!.width).toBe(before!.width);
|
||||
});
|
||||
|
||||
async function openWindowOperationMenu(page: Page, title: string) {
|
||||
const window = page.locator(`section[aria-label='${title}']`);
|
||||
await page.getByRole("button", { name: `切换到${title}` }).click();
|
||||
await window.hover();
|
||||
await page.getByRole("button", { name: `打开${title}窗口操作` }).click();
|
||||
}
|
||||
test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openFirstArtifact(page);
|
||||
|
||||
async function applyTaskViewLayout(page: Page, layoutName: string) {
|
||||
await page.getByRole("button", { name: "任务视图" }).click();
|
||||
await page.getByRole("button", { name: new RegExp(`^${layoutName}:`) }).click();
|
||||
await page.getByRole("button", { name: "专注分析" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "显示地图" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "将成果收起为预览" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toBeVisible();
|
||||
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "销毁预览成果" }).click();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toHaveCount(0);
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
});
|
||||
|
||||
test("non-spatial Artifacts open directly in focus mode", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||
await page.getByRole("button", { name: "销毁预览成果" }).click();
|
||||
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||
await page.getByRole("button", { name: "打开成果" }).click();
|
||||
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析" })).toBeVisible();
|
||||
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "显示地图" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("narrow workspaces switch between Artifact and map as pages", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 820 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openFirstArtifact(page);
|
||||
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await page.getByRole("button", { name: "显示地图" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_only");
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
await expect(page.locator("#artifact-workspace")).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "返回分析成果" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await expect(page.locator("#artifact-workspace")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the condition feed remains floating and starts closed", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await prepareMainFrame(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
async function openFirstArtifact(page: Page) {
|
||||
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||
await page.getByRole("button", { name: "打开成果" }).click();
|
||||
}
|
||||
|
||||
async function prepareMainFrame(page: Page) {
|
||||
|
||||
Reference in New Issue
Block a user