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.backdropFilter).toBe("none");
|
||||||
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
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;
|
panelProps: AgentPanelProps;
|
||||||
panelOpen: boolean;
|
panelOpen: boolean;
|
||||||
panelCollapsing: boolean;
|
panelCollapsing: boolean;
|
||||||
|
artifactActive: boolean;
|
||||||
conditionExpanded: boolean;
|
conditionExpanded: boolean;
|
||||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
@@ -23,6 +24,7 @@ export function WorkbenchAgentPanels({
|
|||||||
panelProps,
|
panelProps,
|
||||||
panelOpen,
|
panelOpen,
|
||||||
panelCollapsing,
|
panelCollapsing,
|
||||||
|
artifactActive,
|
||||||
conditionExpanded,
|
conditionExpanded,
|
||||||
personaState,
|
personaState,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
@@ -42,14 +44,20 @@ export function WorkbenchAgentPanels({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={resize.panelRef}
|
ref={resize.panelRef}
|
||||||
className="pointer-events-none absolute bottom-14 left-3 top-24 z-50 hidden max-w-[720px] lg:block"
|
className={cn(
|
||||||
style={{ width: resize.width ?? defaultWidth }}
|
"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 ? (
|
{panelOpen ? (
|
||||||
<>
|
<>
|
||||||
<AgentCommandPanel
|
<AgentCommandPanel
|
||||||
{...panelProps}
|
{...panelProps}
|
||||||
presentation="desktop-floating"
|
presentation={artifactActive ? "desktop-floating" : "desktop-dock"}
|
||||||
collapsing={panelCollapsing}
|
collapsing={panelCollapsing}
|
||||||
onCollapse={onCollapsePanel}
|
onCollapse={onCollapsePanel}
|
||||||
/>
|
/>
|
||||||
@@ -73,11 +81,13 @@ export function WorkbenchAgentPanels({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<AgentCollapsedRail
|
<AgentCollapsedRail
|
||||||
personaState={personaState}
|
personaState={personaState}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
onExpand={onExpandPanel}
|
onExpand={onExpandPanel}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ export function WorkbenchTopBar({
|
|||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="测试打开 Agent 多图表窗口"
|
aria-label="生成 Agent 模拟分析成果"
|
||||||
title="测试打开 Agent 多图表窗口"
|
title="生成 Agent 模拟分析成果"
|
||||||
data-selection-tone="soft"
|
data-selection-tone="soft"
|
||||||
onClick={onCreateTestAnalysis}
|
onClick={onCreateTestAnalysis}
|
||||||
className={cn("group px-2", headerControlButtonClassName)}
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ describe("workbench responsive layout", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.current.isLargeScreen).toBe(true);
|
expect(result.current.isLargeScreen).toBe(true);
|
||||||
|
expect(result.current.shouldShowConditionFeed).toBe(false);
|
||||||
|
|
||||||
|
act(() => result.current.toggleConditionFeedForViewport());
|
||||||
expect(result.current.shouldShowConditionFeed).toBe(true);
|
expect(result.current.shouldShowConditionFeed).toBe(true);
|
||||||
|
|
||||||
act(() => result.current.handleConditionExpandedChange(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_EXIT_MS = 170;
|
||||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||||
const CONDITION_FEED_DEFAULT_MIN_WIDTH = 1280;
|
|
||||||
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
||||||
|
|
||||||
type WorkbenchMobileSheet = "agent" | "condition" | null;
|
type WorkbenchMobileSheet = "agent" | "condition" | null;
|
||||||
@@ -32,7 +31,7 @@ export function useWorkbenchResponsiveLayout({
|
|||||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||||
);
|
);
|
||||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||||
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||||
@@ -49,7 +48,7 @@ export function useWorkbenchResponsiveLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleViewportChange();
|
handleViewportChange();
|
||||||
setConditionFeedVisible(window.innerWidth >= CONDITION_FEED_DEFAULT_MIN_WIDTH);
|
setConditionFeedVisible(false);
|
||||||
mediaQuery.addEventListener("change", handleViewportChange);
|
mediaQuery.addEventListener("change", handleViewportChange);
|
||||||
window.addEventListener("resize", handleViewportChange);
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,14 @@ export type {
|
|||||||
LineLayerGroupStylePatch,
|
LineLayerGroupStylePatch,
|
||||||
PointLayerGroupStylePatch
|
PointLayerGroupStylePatch
|
||||||
} from "./map/layer-group-style";
|
} 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";
|
} from "./components/toolbar-panel";
|
||||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||||
import {
|
import {
|
||||||
WorkbenchMainFrame,
|
WorkbenchMainFrame
|
||||||
type WorkbenchMainFrameHandle
|
|
||||||
} from "./workspace/workbench-main-frame";
|
} from "./workspace/workbench-main-frame";
|
||||||
|
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
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 hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mainFrameRef = useRef<WorkbenchMainFrameHandle | null>(null);
|
|
||||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||||
const [impactVisible, setImpactVisible] = useState(false);
|
const [impactVisible, setImpactVisible] = useState(false);
|
||||||
@@ -147,6 +146,8 @@ export function MapWorkbenchPage({
|
|||||||
onFrontendAction: handleFrontendAction,
|
onFrontendAction: handleFrontendAction,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
});
|
});
|
||||||
|
const collapseAgentPanel = agent.collapsePanel;
|
||||||
|
const expandAgentPanel = agent.expandPanel;
|
||||||
const clearActiveTool = useCallback(() => {
|
const clearActiveTool = useCallback(() => {
|
||||||
setActiveToolId(null);
|
setActiveToolId(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -173,10 +174,22 @@ export function MapWorkbenchPage({
|
|||||||
activeToolOpen: activeToolId !== null,
|
activeToolOpen: activeToolId !== null,
|
||||||
agentPanelOpen: agent.panelOpen,
|
agentPanelOpen: agent.panelOpen,
|
||||||
devPanelOpen,
|
devPanelOpen,
|
||||||
collapseAgentPanel: agent.collapsePanel,
|
collapseAgentPanel,
|
||||||
expandAgentPanel: agent.expandPanel,
|
expandAgentPanel,
|
||||||
onClearActiveTool: clearActiveTool
|
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() {
|
function openAgentWorkspaceForViewport() {
|
||||||
openAgentPanelForViewport();
|
openAgentPanelForViewport();
|
||||||
@@ -199,7 +212,7 @@ export function MapWorkbenchPage({
|
|||||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen,
|
leftPanelOpen: agentOverlaysWorkspace,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
@@ -238,7 +251,11 @@ export function MapWorkbenchPage({
|
|||||||
});
|
});
|
||||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||||
const shouldShowTaskTicker =
|
const shouldShowTaskTicker =
|
||||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
!artifactActive
|
||||||
|
&& mobileSheet === null
|
||||||
|
&& !conditionFeedExpanded
|
||||||
|
&& taskTickerVisible
|
||||||
|
&& taskTickerAvailable;
|
||||||
const taskTickerEnterState = prefersReducedMotion
|
const taskTickerEnterState = prefersReducedMotion
|
||||||
? {
|
? {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -618,7 +635,7 @@ export function MapWorkbenchPage({
|
|||||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||||
padding: getResponsiveWorkbenchPadding(
|
padding: getResponsiveWorkbenchPadding(
|
||||||
map,
|
map,
|
||||||
leftPanelOpen,
|
agentOverlaysWorkspace,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
@@ -776,7 +793,11 @@ export function MapWorkbenchPage({
|
|||||||
{
|
{
|
||||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||||
"--workbench-agent-current-width": `${
|
"--workbench-agent-current-width": `${
|
||||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
artifactActive
|
||||||
|
? WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||||
|
: leftPanelOpen
|
||||||
|
? agentPanelWidth
|
||||||
|
: WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||||
}px`
|
}px`
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -802,7 +823,7 @@ export function MapWorkbenchPage({
|
|||||||
onCompareScenario={handleCompareScenario}
|
onCompareScenario={handleCompareScenario}
|
||||||
onExportScenarioReport={handleExportScenarioReport}
|
onExportScenarioReport={handleExportScenarioReport}
|
||||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||||
onCreateTestAnalysis={devPanelEnabled ? () => mainFrameRef.current?.openTestAnalysis() : undefined}
|
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
|
||||||
onShowDataStatus={handleShowDataStatus}
|
onShowDataStatus={handleShowDataStatus}
|
||||||
onRefreshTiles={handleRefreshTiles}
|
onRefreshTiles={handleRefreshTiles}
|
||||||
onShowShortcuts={handleShowShortcuts}
|
onShowShortcuts={handleShowShortcuts}
|
||||||
@@ -811,7 +832,15 @@ export function MapWorkbenchPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkbenchMainFrame
|
<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={(
|
mapContent={(
|
||||||
<div
|
<div
|
||||||
ref={mapContainerRef}
|
ref={mapContainerRef}
|
||||||
@@ -842,6 +871,7 @@ export function MapWorkbenchPage({
|
|||||||
panelProps={agentPanelProps}
|
panelProps={agentPanelProps}
|
||||||
panelOpen={agent.panelOpen}
|
panelOpen={agent.panelOpen}
|
||||||
panelCollapsing={agent.panelCollapsing}
|
panelCollapsing={agent.panelCollapsing}
|
||||||
|
artifactActive={artifactActive}
|
||||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||||
personaState={agent.personaState}
|
personaState={agent.personaState}
|
||||||
statusLabel={agent.statusLabel}
|
statusLabel={agent.statusLabel}
|
||||||
@@ -880,7 +910,7 @@ export function MapWorkbenchPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mobileSheet === null ? (
|
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||||
<ToolbarPanel
|
<ToolbarPanel
|
||||||
{...toolbarPanelProps}
|
{...toolbarPanelProps}
|
||||||
@@ -912,7 +942,7 @@ export function MapWorkbenchPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</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 ? (
|
{mobileSheet === null ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -924,11 +954,13 @@ export function MapWorkbenchPage({
|
|||||||
>
|
>
|
||||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||||
</button>
|
</button>
|
||||||
|
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<MapToolbar
|
<MapToolbar
|
||||||
items={toolbarItems}
|
items={toolbarItems}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className="h-11 justify-center"
|
className="h-11 justify-center"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="打开工况任务"
|
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) => ({
|
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
id,
|
id,
|
||||||
|
type: "diagnosis",
|
||||||
|
lifecycle: "reviewed",
|
||||||
|
revision: 3,
|
||||||
title: "北辰分区压力异常诊断",
|
title: "北辰分区压力异常诊断",
|
||||||
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||||
|
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
|
||||||
|
scope: "北辰供水分区 · 最近 6 小时",
|
||||||
|
confidence: "92.6%",
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
preferredView: "map_split",
|
||||||
|
mapRelation: "required",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "pressure-metrics",
|
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%" }
|
{ 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) => ({
|
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
id,
|
id,
|
||||||
|
type: "metric_analysis",
|
||||||
|
lifecycle: "draft",
|
||||||
|
revision: 2,
|
||||||
title: "供水服务分区平衡分析",
|
title: "供水服务分区平衡分析",
|
||||||
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||||
|
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
|
||||||
|
scope: "六个服务分区 · 今日峰值预测",
|
||||||
|
confidence: "88.4%",
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
preferredView: "focus",
|
||||||
|
mapRelation: "none",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "zone-metrics",
|
id: "zone-metrics",
|
||||||
@@ -129,14 +151,28 @@ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
|||||||
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
"执行前需复核三泵站 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) => ({
|
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||||
id,
|
id,
|
||||||
|
type: "simulation",
|
||||||
|
lifecycle: "published",
|
||||||
|
revision: 5,
|
||||||
title: "低压事件调度处置流程",
|
title: "低压事件调度处置流程",
|
||||||
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||||
|
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
|
||||||
|
scope: "北辰低压事件 · 30 分钟模拟",
|
||||||
|
confidence: "90.8%",
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
preferredView: "map_split",
|
||||||
|
mapRelation: "required",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "flow-metrics",
|
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" }
|
{ 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;
|
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 factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return factory(
|
return factory(
|
||||||
|
|||||||
@@ -1,87 +1,244 @@
|
|||||||
import ReactECharts from "echarts-for-react";
|
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 { cn } from "@/shared/ui/cn";
|
||||||
import type {
|
import type {
|
||||||
|
AnalysisArtifact,
|
||||||
|
AnalysisArtifactAction,
|
||||||
AnalysisBlock,
|
AnalysisBlock,
|
||||||
AnalysisDocument,
|
AnalysisMetric,
|
||||||
AnalysisMetric
|
ArtifactRequestedView,
|
||||||
|
WorkbenchSurfaceMode
|
||||||
} from "./workspace-model";
|
} from "./workspace-model";
|
||||||
|
|
||||||
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
|
type AnalysisArtifactWorkspaceProps = {
|
||||||
return (
|
artifact: AnalysisArtifact;
|
||||||
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
|
surfaceMode: WorkbenchSurfaceMode;
|
||||||
<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">
|
mapSplitAvailable: boolean;
|
||||||
<div className="min-w-0">
|
onSetView: (view: ArtifactRequestedView) => void;
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
|
onCollapse: () => void;
|
||||||
<Sparkles size={14} aria-hidden="true" />
|
onDestroy: () => void;
|
||||||
Agent 临时分析文档
|
};
|
||||||
</div>
|
|
||||||
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
|
|
||||||
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
|
|
||||||
<Clock3 size={13} aria-hidden="true" />
|
|
||||||
{document.generatedAt}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
|
export function AnalysisArtifactWorkspace({
|
||||||
{document.blocks.map((block) => (
|
artifact,
|
||||||
<AnalysisBlockView key={block.id} block={block} />
|
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>
|
</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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnalysisBlockView({ block }: { block: AnalysisBlock }) {
|
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||||
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 (
|
return (
|
||||||
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
|
<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">
|
||||||
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
|
<div className="flex items-start justify-between gap-5">
|
||||||
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
|
<div className="min-w-0">
|
||||||
{block.metrics.map((metric) => (
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||||
<MetricTile key={metric.label} metric={metric} />
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
|
||||||
if (block.kind === "chart") {
|
if (block.kind === "chart") {
|
||||||
return (
|
return (
|
||||||
<AnalysisSection block={block} className={spanClass}>
|
|
||||||
<div className="h-[258px] min-h-0">
|
<div className="h-[258px] min-h-0">
|
||||||
<ReactECharts
|
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||||||
notMerge
|
|
||||||
lazyUpdate
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
option={createChartOption(block)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AnalysisSection>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.kind === "process-flow") {
|
if (block.kind === "process-flow") {
|
||||||
return (
|
return (
|
||||||
<AnalysisSection block={block} className={spanClass}>
|
|
||||||
<ol className="space-y-0" aria-label={block.title}>
|
<ol className="space-y-0" aria-label={block.title}>
|
||||||
{block.nodes.map((node, index) => (
|
{block.nodes.map((node, index) => (
|
||||||
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||||||
{index < block.nodes.length - 1 ? (
|
{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="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" />
|
<span className={cn(
|
||||||
) : null}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
"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 === "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 === "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 === "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" />}
|
{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>
|
</span>
|
||||||
<div className="min-w-0 pt-0.5">
|
<div className="min-w-0 pt-0.5">
|
||||||
@@ -94,82 +251,175 @@ function AnalysisBlockView({ block }: { block: AnalysisBlock }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</AnalysisSection>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.kind === "data-table") {
|
if (block.kind === "data-table") {
|
||||||
return (
|
return (
|
||||||
<AnalysisSection block={block} className={spanClass}>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||||||
{block.columns.map((column) => (
|
{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>)}
|
||||||
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{block.rows.map((row, index) => (
|
{block.rows.map((row, index) => (
|
||||||
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||||||
{block.columns.map((column) => (
|
{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>)}
|
||||||
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</AnalysisSection>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<AnalysisSection block={block} className={spanClass}>
|
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
|
||||||
<div className="space-y-3 text-sm leading-7 text-slate-700">
|
<div className="bg-[#e7edf3] px-4 py-3.5">
|
||||||
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
<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>
|
</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,
|
block,
|
||||||
className,
|
onClose
|
||||||
children
|
|
||||||
}: {
|
}: {
|
||||||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
artifact: AnalysisArtifact;
|
||||||
className: string;
|
block: AnalysisBlock;
|
||||||
children: React.ReactNode;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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`}>
|
<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)]">
|
||||||
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
|
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
|
||||||
<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>
|
||||||
<div className="min-w-0">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
|
||||||
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
|
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
|
||||||
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<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">
|
||||||
<div className="p-4">{children}</div>
|
<X size={18} aria-hidden="true" />
|
||||||
</section>
|
</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 }) {
|
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
<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>
|
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
<p className="mt-2 text-lg 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-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||||||
</div>
|
</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" }>) {
|
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||||
return {
|
return {
|
||||||
animationDuration: 260,
|
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 {
|
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
|
||||||
Map as MapIcon,
|
import type { ReactNode } from "react";
|
||||||
PanelsTopLeft,
|
|
||||||
Sparkles
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode
|
|
||||||
} from "react";
|
|
||||||
import { showMapNotice } from "@/features/map/core";
|
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { AnalysisDocumentView } from "./analysis-document-view";
|
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||||
import {
|
import type {
|
||||||
MAX_ANALYSIS_WINDOWS,
|
AnalysisArtifact,
|
||||||
applyWorkspaceLayout,
|
ArtifactRequestedView,
|
||||||
closeAnalysisWindow,
|
WorkbenchSurfaceMode
|
||||||
createInitialWorkspaceState,
|
|
||||||
focusWorkspaceWindow,
|
|
||||||
maximizeWorkspaceWindow,
|
|
||||||
minimizeWorkspaceWindow,
|
|
||||||
openAnalysisDocument,
|
|
||||||
resizeWorkspace,
|
|
||||||
restoreWorkspaceWindow,
|
|
||||||
swapWorkspaceWindows,
|
|
||||||
updateWorkspaceWindowRect,
|
|
||||||
type WorkspaceBounds,
|
|
||||||
type WorkspaceLayoutMode,
|
|
||||||
type WorkspaceRect,
|
|
||||||
type WorkspaceState
|
|
||||||
} from "./workspace-model";
|
} 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 = {
|
type WorkbenchMainFrameProps = {
|
||||||
mapContent: ReactNode;
|
mapContent: ReactNode;
|
||||||
mapOverlay?: 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 = {
|
export function WorkbenchMainFrame({
|
||||||
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({
|
|
||||||
mapContent,
|
mapContent,
|
||||||
mapOverlay
|
mapOverlay,
|
||||||
}, ref) {
|
activeArtifact,
|
||||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
pendingArtifact,
|
||||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
surfaceMode,
|
||||||
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
|
mapSplitAvailable,
|
||||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
onOpenPendingArtifact,
|
||||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
onDestroyPendingArtifact,
|
||||||
const [desktop, setDesktop] = useState(false);
|
onSetArtifactView,
|
||||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
onCollapseArtifact,
|
||||||
const [taskViewOpen, setTaskViewOpen] = useState(false);
|
onDestroyArtifact
|
||||||
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
|
}: WorkbenchMainFrameProps) {
|
||||||
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
const showMap = surfaceMode !== "focus";
|
||||||
const fixtureIndexRef = useRef(0);
|
const showArtifact = activeArtifact && surfaceMode !== "map_only";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 lg:left-[var(--workbench-agent-current-width)]"
|
||||||
data-testid="workbench-main-frame"
|
data-testid="workbench-main-frame"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={workspaceRef}
|
|
||||||
id="main-workspace"
|
id="main-workspace"
|
||||||
data-layout-mode={workspace.layout.mode}
|
data-layout-mode={surfaceMode}
|
||||||
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
|
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
|
<section
|
||||||
id={workspace.map.id}
|
id="workspace-map"
|
||||||
title={workspace.map.title}
|
aria-label="供水管网地图工作区"
|
||||||
mode={desktop ? workspace.map.mode : "docked"}
|
aria-hidden={!showMap}
|
||||||
rect={workspace.map.rect}
|
className={cn(
|
||||||
zIndex={workspace.map.zIndex}
|
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||||
active={workspace.activeWindowId === workspace.map.id}
|
!showMap && "hidden"
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
{mapContent}
|
{mapContent}
|
||||||
{mapOverlay}
|
{mapOverlay}
|
||||||
</WorkspaceWindow>
|
{activeArtifact && surfaceMode === "map_only" ? (
|
||||||
|
<button
|
||||||
{workspace.analyses.map((window, index) => (
|
type="button"
|
||||||
<WorkspaceWindow
|
onClick={() => onSetArtifactView("focus")}
|
||||||
key={window.id}
|
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"
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<AnalysisDocumentView document={window.document} />
|
<ArrowLeft size={15} aria-hidden="true" />
|
||||||
</WorkspaceWindow>
|
<FileChartColumnIncreasing size={15} aria-hidden="true" />
|
||||||
))}
|
返回分析成果
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
{taskViewOpen ? (
|
{activeArtifact ? (
|
||||||
<WorkspaceTaskView
|
<section
|
||||||
windows={taskViewWindows}
|
id="artifact-workspace"
|
||||||
previewRects={taskViewPreviewRects}
|
aria-label={`${activeArtifact.title}工作区`}
|
||||||
focusedWindowId={taskViewFocusWindowId}
|
aria-hidden={!showArtifact}
|
||||||
layoutMode={workspace.layout.mode}
|
className={cn("relative min-h-0 min-w-0 overflow-hidden", !showArtifact && "hidden")}
|
||||||
onClose={closeTaskView}
|
>
|
||||||
onFocusWindow={setTaskViewFocusWindowId}
|
<AnalysisArtifactWorkspace
|
||||||
onSelectWindow={activateTaskViewWindow}
|
artifact={activeArtifact}
|
||||||
onWindowKeyDown={handleTaskViewWindowKeyDown}
|
surfaceMode={surfaceMode}
|
||||||
onLayoutChange={applyTaskViewLayout}
|
mapSplitAvailable={mapSplitAvailable}
|
||||||
|
onSetView={onSetArtifactView}
|
||||||
|
onCollapse={onCollapseArtifact}
|
||||||
|
onDestroy={onDestroyArtifact}
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{pendingArtifact ? (
|
||||||
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"
|
<ArtifactPeekBar
|
||||||
role="toolbar"
|
artifact={pendingArtifact}
|
||||||
aria-label="工作区窗口任务栏"
|
surfaceMode={surfaceMode}
|
||||||
>
|
onOpen={onOpenPendingArtifact}
|
||||||
<button
|
onDestroy={onDestroyPendingArtifact}
|
||||||
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));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{workspace.analyses.map((window) => (
|
) : null}
|
||||||
<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}
|
|
||||||
</div>
|
</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 { describe, expect, it } from "vitest";
|
||||||
|
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||||
import {
|
import {
|
||||||
MAX_ANALYSIS_WINDOWS,
|
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||||
applyWorkspaceLayout,
|
collapseActiveArtifact,
|
||||||
closeAnalysisWindow,
|
createInitialArtifactWorkspaceState,
|
||||||
createInitialWorkspaceState,
|
destroyActiveArtifact,
|
||||||
maximizeWorkspaceWindow,
|
destroyPendingArtifact,
|
||||||
minimizeWorkspaceWindow,
|
openPendingArtifact,
|
||||||
openAnalysisDocument,
|
previewAnalysisArtifact,
|
||||||
resizeWorkspace,
|
requestArtifactView,
|
||||||
restoreWorkspaceWindow,
|
resolveWorkbenchSurfaceMode
|
||||||
setWorkspacePrimaryWindow,
|
|
||||||
swapWorkspaceWindows,
|
|
||||||
updateWorkspaceWindowRect,
|
|
||||||
type AnalysisDocument,
|
|
||||||
type WorkspaceState
|
|
||||||
} from "./workspace-model";
|
} 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 {
|
expect(previewed.pendingArtifact).toBe(artifact);
|
||||||
return {
|
expect(previewed.activeArtifact).toBeNull();
|
||||||
id: `analysis-${index}`,
|
expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default");
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports minimize, maximize and bounded restore", () => {
|
it("opens a map-related artifact in split mode only when enough width exists", () => {
|
||||||
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
const minimized = minimizeWorkspaceWindow(opened, "analysis-1");
|
const opened = openPendingArtifact(
|
||||||
expect(minimized.analyses[0]?.mode).toBe("minimized");
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const resized = resizeWorkspace(moved, { width: 800, height: 600 });
|
expect(opened.activeArtifact).toBe(artifact);
|
||||||
const analysis = resized.analyses[0];
|
expect(opened.pendingArtifact).toBeNull();
|
||||||
expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 });
|
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split");
|
||||||
expect(analysis?.restoreRect).toEqual(analysis?.rect);
|
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
|
||||||
expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788);
|
|
||||||
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspace layout presets", () => {
|
it("keeps non-spatial artifacts focused at every desktop width", () => {
|
||||||
it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => {
|
const artifact = createAnalysisArtifactFixture(1, 0);
|
||||||
const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds);
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
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);
|
|
||||||
|
|
||||||
const grid = applyWorkspaceLayout(split, "grid", bounds);
|
|
||||||
expect(visibleIds(grid)).toHaveLength(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
const resized = resizeWorkspace(restored, { width: 900, height: 620 });
|
|
||||||
expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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")
|
|
||||||
);
|
|
||||||
expect(resized.map.rect.width).toBeGreaterThan(resized.analyses.find((window) => window.id === "analysis-2")!.rect.width);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(moved.layout.mode).toBe("free");
|
expect(artifact.mapRelation).toBe("none");
|
||||||
expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({
|
expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
|
||||||
x: 90,
|
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
|
||||||
y: 70,
|
|
||||||
width: 420,
|
|
||||||
height: 360
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus");
|
||||||
|
expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only");
|
||||||
|
expect(mapOnly.activeArtifact).toBe(artifact);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the active artifact back into a peek", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
const collapsed = collapseActiveArtifact(opened);
|
||||||
|
|
||||||
|
expect(collapsed.activeArtifact).toBeNull();
|
||||||
|
expect(collapsed.pendingArtifact).toBe(artifact);
|
||||||
|
expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
const withNewPreview = previewAnalysisArtifact(opened, second);
|
||||||
|
|
||||||
|
expect(withNewPreview.activeArtifact).toBe(first);
|
||||||
|
expect(withNewPreview.pendingArtifact).toBe(second);
|
||||||
|
expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys pending and active artifacts independently", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
|
||||||
|
const opened = openPendingArtifact(previewed);
|
||||||
|
|
||||||
|
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 = {
|
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||||
x: number;
|
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||||
y: number;
|
export type ArtifactViewMode = "map_split" | "focus";
|
||||||
width: number;
|
export type ArtifactRequestedView = ArtifactViewMode | "map_only";
|
||||||
height: number;
|
export type ArtifactMapRelation = "required" | "optional" | "none";
|
||||||
};
|
export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
|
||||||
|
|
||||||
export type WorkspaceBounds = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
|
|
||||||
export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
|
|
||||||
|
|
||||||
export type AnalysisMetric = {
|
export type AnalysisMetric = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -78,548 +70,100 @@ export type AnalysisBlock =
|
|||||||
paragraphs: string[];
|
paragraphs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AnalysisDocument = {
|
export type AnalysisArtifactAction = {
|
||||||
id: string;
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone: "primary" | "neutral" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisArtifact = {
|
||||||
|
id: string;
|
||||||
|
type: ArtifactType;
|
||||||
|
lifecycle: ArtifactLifecycle;
|
||||||
|
revision: number;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
summary: string;
|
||||||
|
scope: string;
|
||||||
|
confidence: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
|
preferredView: ArtifactViewMode;
|
||||||
|
mapRelation: ArtifactMapRelation;
|
||||||
blocks: AnalysisBlock[];
|
blocks: AnalysisBlock[];
|
||||||
|
sources: string[];
|
||||||
|
limitations: string[];
|
||||||
|
actions: AnalysisArtifactAction[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceWindowState = {
|
export type ArtifactWorkspaceState = {
|
||||||
mode: WorkspaceWindowMode;
|
pendingArtifact: AnalysisArtifact | null;
|
||||||
rect: WorkspaceRect;
|
activeArtifact: AnalysisArtifact | null;
|
||||||
restoreRect: WorkspaceRect;
|
requestedView: ArtifactRequestedView;
|
||||||
zIndex: number;
|
|
||||||
minimizedByLayout: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AnalysisWorkspaceWindow = WorkspaceWindowState & {
|
export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
|
||||||
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);
|
|
||||||
return {
|
return {
|
||||||
map: {
|
pendingArtifact: null,
|
||||||
id: MAP_WINDOW_ID,
|
activeArtifact: null,
|
||||||
kind: "map",
|
requestedView: "map_split"
|
||||||
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] }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openAnalysisDocument(
|
export function previewAnalysisArtifact(
|
||||||
state: WorkspaceState,
|
state: ArtifactWorkspaceState,
|
||||||
document: AnalysisDocument,
|
artifact: AnalysisArtifact
|
||||||
bounds: WorkspaceBounds
|
): ArtifactWorkspaceState {
|
||||||
): WorkspaceState {
|
return { ...state, pendingArtifact: artifact };
|
||||||
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
|
|
||||||
|
|
||||||
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
|
|
||||||
const window: AnalysisWorkspaceWindow = {
|
|
||||||
id: document.id,
|
|
||||||
kind: "agent-analysis",
|
|
||||||
title: document.title,
|
|
||||||
mode: "floating",
|
|
||||||
rect,
|
|
||||||
restoreRect: rect,
|
|
||||||
zIndex: state.nextZIndex,
|
|
||||||
minimizedByLayout: false,
|
|
||||||
document
|
|
||||||
};
|
|
||||||
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(
|
export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
state: WorkspaceState,
|
if (!state.pendingArtifact) return state;
|
||||||
windowId: string,
|
return {
|
||||||
bounds: WorkspaceBounds = FALLBACK_BOUNDS
|
pendingArtifact: null,
|
||||||
): WorkspaceState {
|
activeArtifact: state.pendingArtifact,
|
||||||
const analyses = state.analyses.filter((window) => window.id !== windowId);
|
requestedView: getPreferredArtifactView(state.pendingArtifact)
|
||||||
if (analyses.length === state.analyses.length) return state;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (analyses.length === 0) {
|
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
|
if (!state.activeArtifact) return state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
map: {
|
pendingArtifact: state.activeArtifact,
|
||||||
...state.map,
|
activeArtifact: null
|
||||||
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);
|
export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
const nextActive = state.activeWindowId === windowId
|
return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
|
||||||
? 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 {
|
export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
if (!getWindowById(state, windowId)) return state;
|
return state.activeArtifact
|
||||||
return updateWindowById(
|
? { ...state, activeArtifact: null, requestedView: "map_split" }
|
||||||
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
|
: state;
|
||||||
windowId,
|
|
||||||
(window) => ({ ...window, zIndex: state.nextZIndex })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function minimizeWorkspaceWindow(
|
export function requestArtifactView(
|
||||||
state: WorkspaceState,
|
state: ArtifactWorkspaceState,
|
||||||
windowId: string,
|
view: ArtifactRequestedView
|
||||||
bounds: WorkspaceBounds = FALLBACK_BOUNDS
|
): ArtifactWorkspaceState {
|
||||||
): WorkspaceState {
|
if (!state.activeArtifact) return state;
|
||||||
if (!getWindowById(state, windowId)) return state;
|
return { ...state, requestedView: view };
|
||||||
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 {
|
export function resolveWorkbenchSurfaceMode(
|
||||||
if (!getWindowById(state, windowId)) return state;
|
state: ArtifactWorkspaceState,
|
||||||
return focusWorkspaceWindow(
|
viewportWidth: number
|
||||||
updateWindowById(state, windowId, (window) => ({
|
): WorkbenchSurfaceMode {
|
||||||
...window,
|
const artifact = state.activeArtifact;
|
||||||
mode: "maximized",
|
if (!artifact) return "default";
|
||||||
minimizedByLayout: false
|
if (state.requestedView === "map_only") return "map_only";
|
||||||
})),
|
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
|
||||||
windowId
|
return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreWorkspaceWindow(
|
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
|
||||||
state: WorkspaceState,
|
return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
|
||||||
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);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
map: {
|
|
||||||
...state.map,
|
|
||||||
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
|
|
||||||
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
|
|
||||||
},
|
|
||||||
analyses: state.analyses.map((window) => ({
|
|
||||||
...window,
|
|
||||||
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
|
|
||||||
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampWorkspaceRect(
|
|
||||||
rect: WorkspaceRect,
|
|
||||||
bounds: WorkspaceBounds,
|
|
||||||
kind: "map" | "analysis"
|
|
||||||
): WorkspaceRect {
|
|
||||||
const minimumWidth = kind === "map" ? 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
background: var(--surface-reading);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop Main Frame uses solid, work-focused panes instead of map-overlay acrylic. */
|
.workbench-agent-dock .agent-panel-shell {
|
||||||
.workbench-main-frame .agent-panel-shell,
|
border: 0;
|
||||||
.workbench-main-frame .scheduled-feed-panel-shell,
|
border-radius: 0;
|
||||||
.workbench-main-frame .surface-dock,
|
background: #f7f9fb;
|
||||||
.workbench-main-frame .surface-control,
|
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
||||||
.workbench-main-frame .surface-reading {
|
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
-webkit-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-orbit,
|
||||||
.agent-ready-core {
|
.agent-ready-core {
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
|||||||
@@ -184,11 +184,12 @@ test("Main Frame navigation respects forced color mode", async ({ page }) => {
|
|||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
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).toBeVisible();
|
||||||
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||||
await expect(navigation).toHaveCSS("box-shadow", "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) {
|
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
||||||
|
|||||||
@@ -1,52 +1,45 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockAgentApi } from "./support/mock-agent-api";
|
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" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||||
const workspace = page.locator("#main-workspace");
|
const workspace = page.locator("#main-workspace");
|
||||||
await expect(navigation).toHaveCSS("width", "368px");
|
const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
|
||||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368);
|
expect(expandedWidth).toBeGreaterThan(400);
|
||||||
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||||
await expect(navigation).toHaveCSS("width", "48px");
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "展开工作台导航" }).click();
|
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||||
await expect(navigation).toHaveCSS("width", "368px");
|
await expect(agentPanel).toBeVisible();
|
||||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368);
|
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" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const mainFrame = page.getByTestId("workbench-main-frame");
|
|
||||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
||||||
await prompt.fill("保留尚未发送的诊断问题");
|
await prompt.fill("保留尚未发送的诊断问题");
|
||||||
await mainFrame.getByRole("button", { name: "工况任务", exact: true }).click();
|
const conditionToggle = page
|
||||||
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
|
|
||||||
.locator("header.acrylic-navigation")
|
.locator("header.acrylic-navigation")
|
||||||
.getByRole("button", { name: "工况任务", exact: true });
|
.getByRole("button", { name: "工况任务", exact: true });
|
||||||
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
await conditionToggle.click();
|
||||||
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "48px");
|
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await topBarConditionToggle.click();
|
await expect(conditionPanel).toBeVisible();
|
||||||
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "368px");
|
|
||||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
|
|
||||||
await topBarConditionToggle.click();
|
|
||||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
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 }) => {
|
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 }) => {
|
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await expect(panel).toBeVisible();
|
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 }) => {
|
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
|||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
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 }) => {
|
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
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 }) => {
|
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const reference = page
|
const reference = page
|
||||||
.getByRole("region", { name: "工况任务", exact: true })
|
.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 page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
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 });
|
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
await expect(conditionPanel).toHaveCSS("width", "880px");
|
await expect(conditionPanel).toHaveCSS("width", "880px");
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
|||||||
async function expectDesktopMainFrameGeometry(page: Page) {
|
async function expectDesktopMainFrameGeometry(page: Page) {
|
||||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const workspace = page.locator("#main-workspace");
|
const workspace = page.locator("#main-workspace");
|
||||||
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" });
|
|
||||||
const mapWindow = page.locator("#workspace-map");
|
const mapWindow = page.locator("#workspace-map");
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
await expect(navigation).toHaveCSS("width", "368px");
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
@@ -201,20 +200,16 @@ async function expectDesktopMainFrameGeometry(page: Page) {
|
|||||||
|
|
||||||
const navigationBox = await navigation.boundingBox();
|
const navigationBox = await navigation.boundingBox();
|
||||||
const workspaceBox = await workspace.boundingBox();
|
const workspaceBox = await workspace.boundingBox();
|
||||||
const taskbarBox = await taskbar.boundingBox();
|
|
||||||
const mapBox = await mapWindow.boundingBox();
|
const mapBox = await mapWindow.boundingBox();
|
||||||
const agentBox = await agentPanel.boundingBox();
|
const agentBox = await agentPanel.boundingBox();
|
||||||
expect(navigationBox).not.toBeNull();
|
expect(navigationBox).not.toBeNull();
|
||||||
expect(workspaceBox).not.toBeNull();
|
expect(workspaceBox).not.toBeNull();
|
||||||
expect(taskbarBox).not.toBeNull();
|
|
||||||
expect(mapBox).not.toBeNull();
|
expect(mapBox).not.toBeNull();
|
||||||
expect(agentBox).not.toBeNull();
|
expect(agentBox).not.toBeNull();
|
||||||
expect(navigationBox!.x).toBe(0);
|
expect(navigationBox!.x).toBe(0);
|
||||||
expect(navigationBox!.y).toBe(56);
|
expect(navigationBox!.y).toBe(56);
|
||||||
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
|
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
|
||||||
expect(workspaceBox!.y).toBe(navigationBox!.y);
|
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(mapBox).toEqual(workspaceBox);
|
||||||
expect(agentBox!.x).toBeGreaterThanOrEqual(48);
|
expect(agentBox!.x).toBeGreaterThanOrEqual(48);
|
||||||
expect(agentBox!.x).toBeLessThanOrEqual(49);
|
expect(agentBox!.x).toBeLessThanOrEqual(49);
|
||||||
|
|||||||
@@ -1,250 +1,132 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockAgentApi } from "./support/mock-agent-api";
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
test("temporary Agent analysis windows 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 prepareMainFrame(page);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const frame = page.getByTestId("workbench-main-frame");
|
const workspace = page.locator("#main-workspace");
|
||||||
const mapWindow = page.locator("#workspace-map");
|
const map = page.locator("#workspace-map");
|
||||||
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
|
|
||||||
|
|
||||||
await expect(frame).toBeVisible();
|
await expect(workspace).toHaveAttribute("data-layout-mode", "default");
|
||||||
await expect(mapWindow).toBeVisible();
|
await expect(map).toBeVisible();
|
||||||
await expect(testButton).toBeVisible();
|
|
||||||
await expect(page.getByLabel("工作台导航")).toHaveCount(0);
|
|
||||||
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||||
await expect(mapWindow.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||||
await expect(page.locator(".workspace-window-titlebar")).toHaveCount(0);
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "任务视图" })).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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 prepareMainFrame(page);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
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 workspace = page.locator("#main-workspace");
|
||||||
const mapWindow = page.locator("#workspace-map");
|
const before = await workspace.boundingBox();
|
||||||
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();
|
|
||||||
expect(before).not.toBeNull();
|
expect(before).not.toBeNull();
|
||||||
expect(workspaceBox).not.toBeNull();
|
|
||||||
|
|
||||||
await page.mouse.move(before!.x + 80, before!.y + 22);
|
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||||
await page.keyboard.down("Alt");
|
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||||
await page.mouse.down();
|
const after = await workspace.boundingBox();
|
||||||
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.mouse.move(before!.x + 80, before!.y + 22);
|
expect(after).not.toBeNull();
|
||||||
await page.keyboard.down("Alt");
|
expect(after!.x).toBe(before!.x);
|
||||||
await page.mouse.down();
|
expect(after!.width).toBe(before!.width);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openWindowOperationMenu(page: Page, title: string) {
|
test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
|
||||||
const window = page.locator(`section[aria-label='${title}']`);
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
await page.getByRole("button", { name: `切换到${title}` }).click();
|
await prepareMainFrame(page);
|
||||||
await window.hover();
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.getByRole("button", { name: `打开${title}窗口操作` }).click();
|
await openFirstArtifact(page);
|
||||||
}
|
|
||||||
|
|
||||||
async function applyTaskViewLayout(page: Page, layoutName: string) {
|
await page.getByRole("button", { name: "专注分析" }).click();
|
||||||
await page.getByRole("button", { name: "任务视图" }).click();
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||||
await page.getByRole("button", { name: new RegExp(`^${layoutName}:`) }).click();
|
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) {
|
async function prepareMainFrame(page: Page) {
|
||||||
|
|||||||
Reference in New Issue
Block a user