feat: add adaptive agent workspace windows
This commit is contained in:
+13
-6
@@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
|||||||
expect(hoverBackground).not.toBe(restingBackground);
|
expect(hoverBackground).not.toBe(restingBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => {
|
test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -112,10 +112,17 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as
|
|||||||
expect(menuTransformOrigin).not.toBe("50% 50%");
|
expect(menuTransformOrigin).not.toBe("50% 50%");
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const commandShadow = await agentCommandControl.evaluate(
|
const navigationMaterial = await navigation.evaluate(
|
||||||
(element) => window.getComputedStyle(element).boxShadow
|
(element) => {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
backdropFilter: style.backdropFilter,
|
||||||
|
backgroundColor: style.backgroundColor
|
||||||
|
};
|
||||||
|
}
|
||||||
);
|
);
|
||||||
expect(commandShadow).toBe("none");
|
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||||
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
|
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
||||||
|
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
|
|||||||
const [statusFilter, setStatusFilter] =
|
const [statusFilter, setStatusFilter] =
|
||||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||||
const mobileSheet = presentation === "mobile-sheet";
|
const mobileSheet = presentation === "mobile-sheet";
|
||||||
|
const desktopDock = presentation === "desktop-dock";
|
||||||
|
const compactPresentation = mobileSheet || desktopDock;
|
||||||
const desktopFloating = presentation === "desktop-floating";
|
const desktopFloating = presentation === "desktop-floating";
|
||||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||||
@@ -298,7 +300,7 @@ export function ScheduledConditionFeed({
|
|||||||
: "max-h-[calc(100dvh-8rem)]"
|
: "max-h-[calc(100dvh-8rem)]"
|
||||||
)
|
)
|
||||||
: "h-full rounded-l-2xl rounded-r-none",
|
: "h-full rounded-l-2xl rounded-r-none",
|
||||||
expanded
|
expanded && !desktopDock
|
||||||
? "scheduled-feed-panel-expanded flex flex-col"
|
? "scheduled-feed-panel-expanded flex flex-col"
|
||||||
: "w-[var(--workbench-condition-width)]"
|
: "w-[var(--workbench-condition-width)]"
|
||||||
)
|
)
|
||||||
@@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||||
mobileSheet
|
compactPresentation
|
||||||
? "flex flex-1"
|
? "flex flex-1"
|
||||||
: expanded
|
: expanded
|
||||||
? "scheduled-feed-layout-expanded flex-1"
|
? "scheduled-feed-layout-expanded flex-1"
|
||||||
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative min-h-0 min-w-0 overflow-hidden",
|
"relative min-h-0 min-w-0 overflow-hidden",
|
||||||
mobileSheet
|
compactPresentation
|
||||||
? expanded
|
? expanded
|
||||||
? "scheduled-feed-detail-enter h-full w-full"
|
? "scheduled-feed-detail-enter h-full w-full"
|
||||||
: "hidden"
|
: "hidden"
|
||||||
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 min-w-0 overflow-hidden",
|
"min-h-0 min-w-0 overflow-hidden",
|
||||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ConditionTimelinePanel
|
<ConditionTimelinePanel
|
||||||
|
|||||||
@@ -166,7 +166,18 @@ export function useWorkbenchMap({
|
|||||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let resizeFrame: number | null = null;
|
||||||
const resizeMap = () => map.resize();
|
const resizeMap = () => map.resize();
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (resizeFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeFrame = window.requestAnimationFrame(() => {
|
||||||
|
resizeFrame = null;
|
||||||
|
resizeMap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
window.addEventListener("resize", resizeMap);
|
window.addEventListener("resize", resizeMap);
|
||||||
|
|
||||||
map.on("sourcedata", (event) => {
|
map.on("sourcedata", (event) => {
|
||||||
@@ -198,6 +209,10 @@ export function useWorkbenchMap({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", resizeMap);
|
window.removeEventListener("resize", resizeMap);
|
||||||
|
if (resizeFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeObserver.disconnect();
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
delete window.__waterNetworkMap;
|
delete window.__waterNetworkMap;
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { MapDevPanel } from "./components/map-dev-panel";
|
|||||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||||
import { FeaturePopover } from "./components/feature-popover";
|
import { FeaturePopover } from "./components/feature-popover";
|
||||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
|
||||||
import {
|
import {
|
||||||
ToolbarPanel,
|
ToolbarPanel,
|
||||||
type ExportViewPreset,
|
type ExportViewPreset,
|
||||||
@@ -43,6 +42,10 @@ import {
|
|||||||
type ToolbarToolId
|
type ToolbarToolId
|
||||||
} from "./components/toolbar-panel";
|
} from "./components/toolbar-panel";
|
||||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||||
|
import {
|
||||||
|
WorkbenchMainFrame,
|
||||||
|
type WorkbenchNavigationSection
|
||||||
|
} from "./workspace/workbench-main-frame";
|
||||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
import { 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";
|
||||||
@@ -101,6 +104,8 @@ export function MapWorkbenchPage({
|
|||||||
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 [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||||
|
const [mainFrameSection, setMainFrameSection] = useState<WorkbenchNavigationSection>("agent");
|
||||||
|
const [mainFrameNavigationCollapsed, setMainFrameNavigationCollapsed] = useState(false);
|
||||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||||
const [impactVisible, setImpactVisible] = useState(false);
|
const [impactVisible, setImpactVisible] = useState(false);
|
||||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||||
@@ -149,7 +154,6 @@ export function MapWorkbenchPage({
|
|||||||
agentPanelWidth,
|
agentPanelWidth,
|
||||||
closeMobileSheet,
|
closeMobileSheet,
|
||||||
conditionFeedExpanded,
|
conditionFeedExpanded,
|
||||||
conditionFeedMounted,
|
|
||||||
handleConditionExpandedChange,
|
handleConditionExpandedChange,
|
||||||
isLargeScreen,
|
isLargeScreen,
|
||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
@@ -159,11 +163,8 @@ export function MapWorkbenchPage({
|
|||||||
openConditionFeedForViewport,
|
openConditionFeedForViewport,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
setAgentPanelWidth,
|
|
||||||
setMobileSheetSnap,
|
setMobileSheetSnap,
|
||||||
shouldShowConditionFeed,
|
toggleConditionFeedForViewport
|
||||||
toggleConditionFeedForViewport,
|
|
||||||
viewportWidth
|
|
||||||
} = useWorkbenchResponsiveLayout({
|
} = useWorkbenchResponsiveLayout({
|
||||||
activeToolOpen: activeToolId !== null,
|
activeToolOpen: activeToolId !== null,
|
||||||
agentPanelOpen: agent.panelOpen,
|
agentPanelOpen: agent.panelOpen,
|
||||||
@@ -173,6 +174,31 @@ export function MapWorkbenchPage({
|
|||||||
onClearActiveTool: clearActiveTool
|
onClearActiveTool: clearActiveTool
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openAgentWorkspaceForViewport() {
|
||||||
|
if (isLargeScreen) {
|
||||||
|
setMainFrameSection("agent");
|
||||||
|
setMainFrameNavigationCollapsed(false);
|
||||||
|
}
|
||||||
|
openAgentPanelForViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConditionWorkspaceForViewport() {
|
||||||
|
if (isLargeScreen) {
|
||||||
|
setMainFrameSection("conditions");
|
||||||
|
setMainFrameNavigationCollapsed(false);
|
||||||
|
}
|
||||||
|
openConditionFeedForViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConditionWorkspaceForViewport() {
|
||||||
|
if (isLargeScreen) {
|
||||||
|
setMainFrameSection((current) => current === "conditions" ? "agent" : "conditions");
|
||||||
|
setMainFrameNavigationCollapsed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleConditionFeedForViewport();
|
||||||
|
}
|
||||||
|
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
@@ -182,9 +208,9 @@ export function MapWorkbenchPage({
|
|||||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen,
|
leftPanelOpen: isLargeScreen ? false : leftPanelOpen,
|
||||||
rightPanelOpen,
|
rightPanelOpen: isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: isLargeScreen ? false : rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -442,7 +468,7 @@ export function MapWorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openAgentPanelForViewport();
|
openAgentWorkspaceForViewport();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.submitPrompt(
|
await agent.submitPrompt(
|
||||||
@@ -479,7 +505,7 @@ export function MapWorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openConditionFeedForViewport();
|
openConditionWorkspaceForViewport();
|
||||||
setConditionFocusRequest((current) => ({
|
setConditionFocusRequest((current) => ({
|
||||||
conditionId,
|
conditionId,
|
||||||
requestId: (current?.requestId ?? 0) + 1
|
requestId: (current?.requestId ?? 0) + 1
|
||||||
@@ -601,9 +627,9 @@ export function MapWorkbenchPage({
|
|||||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||||
padding: getResponsiveWorkbenchPadding(
|
padding: getResponsiveWorkbenchPadding(
|
||||||
map,
|
map,
|
||||||
leftPanelOpen,
|
isLargeScreen ? false : leftPanelOpen,
|
||||||
rightPanelOpen,
|
isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||||
rightPanelExpanded,
|
isLargeScreen ? false : rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
),
|
),
|
||||||
duration: trustedAction.durationMs ?? 500
|
duration: trustedAction.durationMs ?? 500
|
||||||
@@ -764,13 +790,6 @@ export function MapWorkbenchPage({
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
ref={mapContainerRef}
|
|
||||||
className="map-grid"
|
|
||||||
style={{ position: "absolute", inset: 0 }}
|
|
||||||
aria-label="供水管网地图"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WorkbenchTopBar
|
<WorkbenchTopBar
|
||||||
dataTime={headerDataTime}
|
dataTime={headerDataTime}
|
||||||
modelName="AquaDispatch v1.0"
|
modelName="AquaDispatch v1.0"
|
||||||
@@ -778,14 +797,14 @@ export function MapWorkbenchPage({
|
|||||||
activeScenarioId={activeScenarioId}
|
activeScenarioId={activeScenarioId}
|
||||||
alerts={workbenchAlerts}
|
alerts={workbenchAlerts}
|
||||||
user={user}
|
user={user}
|
||||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
conditionFeedVisible={isLargeScreen ? mainFrameSection === "conditions" && !mainFrameNavigationCollapsed : mobileSheet === "condition"}
|
||||||
taskTickerAvailable={taskTickerAvailable}
|
taskTickerAvailable={taskTickerAvailable}
|
||||||
taskTickerVisible={taskTickerVisible}
|
taskTickerVisible={taskTickerVisible}
|
||||||
devPanelEnabled={devPanelEnabled}
|
devPanelEnabled={devPanelEnabled}
|
||||||
devPanelOpen={devPanelOpen}
|
devPanelOpen={devPanelOpen}
|
||||||
onSelectScenario={handleSelectScenario}
|
onSelectScenario={handleSelectScenario}
|
||||||
onSelectAlert={handleSelectAlert}
|
onSelectAlert={handleSelectAlert}
|
||||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||||
onPreviewScenario={handlePreviewScenario}
|
onPreviewScenario={handlePreviewScenario}
|
||||||
@@ -799,50 +818,73 @@ export function MapWorkbenchPage({
|
|||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkbenchAgentPanels
|
<WorkbenchMainFrame
|
||||||
panelProps={agentPanelProps}
|
testEnabled={devPanelEnabled}
|
||||||
panelOpen={agent.panelOpen}
|
activeSection={mainFrameSection}
|
||||||
panelCollapsing={agent.panelCollapsing}
|
navigationCollapsed={mainFrameNavigationCollapsed}
|
||||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
onActiveSectionChange={setMainFrameSection}
|
||||||
personaState={agent.personaState}
|
onNavigationCollapsedChange={setMainFrameNavigationCollapsed}
|
||||||
statusLabel={agent.statusLabel}
|
renderAgentPanel={(onCollapse) => (
|
||||||
viewportWidth={viewportWidth}
|
<AgentCommandPanel
|
||||||
onCollapsePanel={agent.collapsePanel}
|
{...agentPanelProps}
|
||||||
onExpandPanel={agent.expandPanel}
|
presentation="desktop-dock"
|
||||||
onWidthCommit={setAgentPanelWidth}
|
onCollapse={onCollapse}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{!devPanelOpen ? (
|
conditionsPanel={(
|
||||||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
|
||||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{conditionFeedMounted && !devPanelOpen ? (
|
|
||||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
|
||||||
<ScheduledConditionFeed
|
<ScheduledConditionFeed
|
||||||
presentation="desktop-floating"
|
presentation="desktop-dock"
|
||||||
conditions={scheduledConditions}
|
conditions={scheduledConditions}
|
||||||
expanded={conditionFeedExpanded}
|
expanded={conditionFeedExpanded}
|
||||||
focusRequest={conditionFocusRequest}
|
focusRequest={conditionFocusRequest}
|
||||||
loading={scheduledConditionsLoading}
|
loading={scheduledConditionsLoading}
|
||||||
visible={shouldShowConditionFeed}
|
visible
|
||||||
selectedConditionId={selectedConditionId}
|
selectedConditionId={selectedConditionId}
|
||||||
onExpandedChange={handleConditionExpandedChange}
|
onExpandedChange={handleConditionExpandedChange}
|
||||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||||
onSelectedConditionChange={setSelectedConditionId}
|
onSelectedConditionChange={setSelectedConditionId}
|
||||||
onExpandAgent={openAgentPanelForViewport}
|
onExpandAgent={openAgentWorkspaceForViewport}
|
||||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||||
onSubmitPrompt={agent.submitPrompt}
|
onSubmitPrompt={agent.submitPrompt}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
layersPanel={(
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
|
||||||
{!devPanelOpen ? (
|
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
|
||||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
|
||||||
<ToolbarPanel {...toolbarPanelProps} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
toolsPanel={(
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
|
||||||
|
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
|
||||||
|
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
mapContent={(
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="map-grid absolute inset-0"
|
||||||
|
style={{ position: "absolute", inset: 0 }}
|
||||||
|
aria-label="供水管网地图"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
mapOverlay={(
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
|
||||||
|
<MapToolbar items={toolbarItems} />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
||||||
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 right-0 flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-end gap-2 pr-2">
|
||||||
|
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||||
|
</div>
|
||||||
|
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{devPanelEnabled && devPanelOpen ? (
|
{devPanelEnabled && devPanelOpen ? (
|
||||||
<MapDevPanel
|
<MapDevPanel
|
||||||
@@ -862,13 +904,6 @@ export function MapWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
|
||||||
<div className="flex items-end gap-2 pr-2">
|
|
||||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
|
||||||
</div>
|
|
||||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{shouldShowTaskTicker ? (
|
{shouldShowTaskTicker ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { AnalysisDocument } from "./workspace-model";
|
||||||
|
|
||||||
|
type FixtureFactory = (id: string, generatedAt: string) => AnalysisDocument;
|
||||||
|
|
||||||
|
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
title: "北辰分区压力异常诊断",
|
||||||
|
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||||
|
generatedAt,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "pressure-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "诊断摘要",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
|
||||||
|
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
|
||||||
|
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
|
||||||
|
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-trend",
|
||||||
|
kind: "chart",
|
||||||
|
title: "关键节点压力曲线",
|
||||||
|
description: "BC-P-014 在 09:40 后持续低于调度下限",
|
||||||
|
span: 7,
|
||||||
|
chartType: "line",
|
||||||
|
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
|
||||||
|
unit: "MPa",
|
||||||
|
series: [
|
||||||
|
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
|
||||||
|
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
|
||||||
|
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-ranking",
|
||||||
|
kind: "chart",
|
||||||
|
title: "异常偏差排名",
|
||||||
|
description: "按相对调度下限的压力偏差排序",
|
||||||
|
span: 5,
|
||||||
|
chartType: "bar",
|
||||||
|
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
|
||||||
|
unit: "%",
|
||||||
|
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-evidence",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "证据链",
|
||||||
|
description: "对诊断结论贡献最高的观测证据",
|
||||||
|
span: 12,
|
||||||
|
columns: [
|
||||||
|
{ key: "time", label: "时间" },
|
||||||
|
{ key: "source", label: "数据源" },
|
||||||
|
{ key: "finding", label: "观测" },
|
||||||
|
{ key: "impact", label: "影响" },
|
||||||
|
{ key: "confidence", label: "置信度", numeric: true }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
|
||||||
|
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
|
||||||
|
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
title: "供水服务分区平衡分析",
|
||||||
|
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||||
|
generatedAt,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "zone-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "分区态势",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
|
||||||
|
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
|
||||||
|
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
|
||||||
|
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-balance",
|
||||||
|
kind: "chart",
|
||||||
|
title: "分区供需对比",
|
||||||
|
description: "北辰与东丽当前需求已接近可用供水能力",
|
||||||
|
span: 7,
|
||||||
|
chartType: "bar",
|
||||||
|
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
|
||||||
|
unit: "万m³/d",
|
||||||
|
series: [
|
||||||
|
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
|
||||||
|
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-risk",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "调配优先级",
|
||||||
|
description: "按负荷率、连续供水和通道可用性综合排序",
|
||||||
|
span: 5,
|
||||||
|
columns: [
|
||||||
|
{ key: "zone", label: "分区" },
|
||||||
|
{ key: "load", label: "负荷率", numeric: true },
|
||||||
|
{ key: "risk", label: "风险" },
|
||||||
|
{ key: "action", label: "建议" }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
|
||||||
|
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
|
||||||
|
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
|
||||||
|
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-note",
|
||||||
|
kind: "narrative",
|
||||||
|
title: "Agent 结论",
|
||||||
|
span: 12,
|
||||||
|
paragraphs: [
|
||||||
|
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
|
||||||
|
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
title: "低压事件调度处置流程",
|
||||||
|
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||||
|
generatedAt,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "flow-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "执行状态",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
|
||||||
|
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
|
||||||
|
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
|
||||||
|
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-process",
|
||||||
|
kind: "process-flow",
|
||||||
|
title: "处置路径",
|
||||||
|
description: "所有调度动作均需在执行前完成权限和现场状态确认",
|
||||||
|
span: 7,
|
||||||
|
nodes: [
|
||||||
|
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
|
||||||
|
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
|
||||||
|
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
|
||||||
|
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
|
||||||
|
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-checklist",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "执行清单",
|
||||||
|
description: "当前阶段需要完成的人员与系统确认",
|
||||||
|
span: 5,
|
||||||
|
columns: [
|
||||||
|
{ key: "owner", label: "责任方" },
|
||||||
|
{ key: "item", label: "确认项" },
|
||||||
|
{ key: "state", label: "状态" }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
|
||||||
|
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
|
||||||
|
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
|
||||||
|
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-progress",
|
||||||
|
kind: "chart",
|
||||||
|
title: "处置前后压力预测",
|
||||||
|
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
|
||||||
|
span: 12,
|
||||||
|
chartType: "line",
|
||||||
|
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
|
||||||
|
unit: "MPa",
|
||||||
|
series: [
|
||||||
|
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
|
||||||
|
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
|
||||||
|
|
||||||
|
export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument {
|
||||||
|
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||||
|
const now = new Date();
|
||||||
|
return factory(
|
||||||
|
`analysis-${now.getTime().toString(36)}-${instance}`,
|
||||||
|
now.toLocaleTimeString("zh-CN", { hour12: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import type {
|
||||||
|
AnalysisBlock,
|
||||||
|
AnalysisDocument,
|
||||||
|
AnalysisMetric
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
|
||||||
|
return (
|
||||||
|
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
|
||||||
|
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
|
||||||
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
|
Agent 临时分析文档
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
|
||||||
|
<Clock3 size={13} aria-hidden="true" />
|
||||||
|
{document.generatedAt}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
|
||||||
|
{document.blocks.map((block) => (
|
||||||
|
<AnalysisBlockView key={block.id} block={block} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisBlockView({ block }: { block: AnalysisBlock }) {
|
||||||
|
const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12";
|
||||||
|
|
||||||
|
if (block.kind === "metric-grid") {
|
||||||
|
return (
|
||||||
|
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
|
||||||
|
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
|
||||||
|
{block.metrics.map((metric) => (
|
||||||
|
<MetricTile key={metric.label} metric={metric} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "chart") {
|
||||||
|
return (
|
||||||
|
<AnalysisSection block={block} className={spanClass}>
|
||||||
|
<div className="h-[258px] min-h-0">
|
||||||
|
<ReactECharts
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
option={createChartOption(block)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AnalysisSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "process-flow") {
|
||||||
|
return (
|
||||||
|
<AnalysisSection block={block} className={spanClass}>
|
||||||
|
<ol className="space-y-0" aria-label={block.title}>
|
||||||
|
{block.nodes.map((node, index) => (
|
||||||
|
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||||||
|
{index < block.nodes.length - 1 ? (
|
||||||
|
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||||||
|
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||||||
|
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||||||
|
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 pt-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||||||
|
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</AnalysisSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "data-table") {
|
||||||
|
return (
|
||||||
|
<AnalysisSection block={block} className={spanClass}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||||||
|
{block.columns.map((column) => (
|
||||||
|
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, index) => (
|
||||||
|
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||||||
|
{block.columns.map((column) => (
|
||||||
|
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</AnalysisSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisSection block={block} className={spanClass}>
|
||||||
|
<div className="space-y-3 text-sm leading-7 text-slate-700">
|
||||||
|
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
|
</div>
|
||||||
|
</AnalysisSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisSection({
|
||||||
|
block,
|
||||||
|
className,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||||
|
className: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}>
|
||||||
|
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
|
||||||
|
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"><FileSearch size={16} aria-hidden="true" /></span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
|
||||||
|
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||||||
|
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||||
|
return {
|
||||||
|
animationDuration: 260,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
color: block.series.map((series) => series.color),
|
||||||
|
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||||||
|
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||||||
|
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||||||
|
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||||
|
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||||
|
series: block.series.map((series) => ({
|
||||||
|
name: series.name,
|
||||||
|
type: series.type ?? block.chartType,
|
||||||
|
data: series.values,
|
||||||
|
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||||||
|
symbol: "circle",
|
||||||
|
symbolSize: 6,
|
||||||
|
barMaxWidth: 24,
|
||||||
|
lineStyle: { width: 2.5 },
|
||||||
|
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||||||
|
if (tone === "danger") return "bg-rose-500";
|
||||||
|
if (tone === "warning") return "bg-amber-500";
|
||||||
|
if (tone === "success") return "bg-emerald-500";
|
||||||
|
if (tone === "info") return "bg-blue-600";
|
||||||
|
return "bg-slate-400";
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
CalendarClock,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
FlaskConical,
|
||||||
|
Layers3,
|
||||||
|
Map as MapIcon,
|
||||||
|
PanelLeft,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
import { showMapNotice } from "@/features/map/core";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { AnalysisDocumentView } from "./analysis-document-view";
|
||||||
|
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||||
|
import {
|
||||||
|
MAX_ANALYSIS_WINDOWS,
|
||||||
|
closeAnalysisWindow,
|
||||||
|
createInitialWorkspaceState,
|
||||||
|
focusWorkspaceWindow,
|
||||||
|
maximizeWorkspaceWindow,
|
||||||
|
minimizeWorkspaceWindow,
|
||||||
|
openAnalysisDocument,
|
||||||
|
resizeWorkspace,
|
||||||
|
restoreWorkspaceWindow,
|
||||||
|
updateWorkspaceWindowRect,
|
||||||
|
type WorkspaceBounds
|
||||||
|
} from "./workspace-model";
|
||||||
|
import { WorkspaceWindow } from "./workspace-window";
|
||||||
|
|
||||||
|
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
|
||||||
|
|
||||||
|
type WorkbenchMainFrameProps = {
|
||||||
|
renderAgentPanel: (onCollapse: () => void) => ReactNode;
|
||||||
|
conditionsPanel: ReactNode;
|
||||||
|
layersPanel: ReactNode;
|
||||||
|
toolsPanel: ReactNode;
|
||||||
|
mapContent: ReactNode;
|
||||||
|
mapOverlay?: ReactNode;
|
||||||
|
testEnabled: boolean;
|
||||||
|
activeSection: WorkbenchNavigationSection;
|
||||||
|
navigationCollapsed: boolean;
|
||||||
|
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
|
||||||
|
onNavigationCollapsedChange: (collapsed: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
|
||||||
|
{ id: "agent", label: "Agent", icon: Bot },
|
||||||
|
{ id: "conditions", label: "工况任务", icon: CalendarClock },
|
||||||
|
{ id: "layers", label: "图层", icon: Layers3 },
|
||||||
|
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||||
|
|
||||||
|
export function WorkbenchMainFrame({
|
||||||
|
renderAgentPanel,
|
||||||
|
conditionsPanel,
|
||||||
|
layersPanel,
|
||||||
|
toolsPanel,
|
||||||
|
mapContent,
|
||||||
|
mapOverlay,
|
||||||
|
testEnabled,
|
||||||
|
activeSection,
|
||||||
|
navigationCollapsed,
|
||||||
|
onActiveSectionChange,
|
||||||
|
onNavigationCollapsedChange
|
||||||
|
}: WorkbenchMainFrameProps) {
|
||||||
|
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||||
|
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||||
|
const [desktop, setDesktop] = useState(false);
|
||||||
|
const fixtureIndexRef = useRef(0);
|
||||||
|
const fixtureInstanceRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = workspaceRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const updateBounds = () => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
const nextBounds = { width: rect.width, height: rect.height };
|
||||||
|
setBounds(nextBounds);
|
||||||
|
setWorkspace((current) => resizeWorkspace(current, nextBounds));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateBounds();
|
||||||
|
const observer = new ResizeObserver(updateBounds);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||||
|
const handleChange = () => {
|
||||||
|
setDesktop(mediaQuery.matches);
|
||||||
|
};
|
||||||
|
handleChange();
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openTestDocument = useCallback(() => {
|
||||||
|
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "warning",
|
||||||
|
title: "临时窗口已达上限",
|
||||||
|
message: "请关闭一个 Agent 分析窗口后继续测试。"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
|
||||||
|
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
|
||||||
|
fixtureInstanceRef.current += 1;
|
||||||
|
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
|
||||||
|
}, [bounds, workspace.analyses.length]);
|
||||||
|
|
||||||
|
const navigationWidth = navigationCollapsed ? 48 : 368;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
||||||
|
data-testid="workbench-main-frame"
|
||||||
|
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
aria-label="工作台导航"
|
||||||
|
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
|
||||||
|
style={{ width: navigationWidth }}
|
||||||
|
>
|
||||||
|
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
|
||||||
|
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
|
||||||
|
{NAVIGATION_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = item.id === activeSection;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={item.label}
|
||||||
|
className={cn(
|
||||||
|
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
|
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
|
||||||
|
)}
|
||||||
|
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
|
||||||
|
>
|
||||||
|
<Icon size={19} aria-hidden="true" />
|
||||||
|
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-auto flex flex-col items-center gap-1">
|
||||||
|
{testEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="测试打开 Agent 多图表窗口"
|
||||||
|
title="测试打开 Agent 多图表窗口"
|
||||||
|
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
onClick={openTestDocument}
|
||||||
|
>
|
||||||
|
<FlaskConical size={19} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
|
||||||
|
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
|
||||||
|
>
|
||||||
|
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden={navigationCollapsed}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
|
||||||
|
navigationCollapsed && "invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
|
||||||
|
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
|
||||||
|
</div>
|
||||||
|
{testEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openTestDocument}
|
||||||
|
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
|
||||||
|
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
|
测试分析窗
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
|
||||||
|
{desktop ? (
|
||||||
|
<>
|
||||||
|
<NavigationPanel active={activeSection === "agent"}>
|
||||||
|
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
|
||||||
|
</NavigationPanel>
|
||||||
|
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
|
||||||
|
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
|
||||||
|
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={workspaceRef}
|
||||||
|
id="main-workspace"
|
||||||
|
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
|
||||||
|
>
|
||||||
|
<WorkspaceWindow
|
||||||
|
id={workspace.map.id}
|
||||||
|
title={workspace.map.title}
|
||||||
|
mode={desktop ? workspace.map.mode : "docked"}
|
||||||
|
rect={workspace.map.rect}
|
||||||
|
zIndex={workspace.map.zIndex}
|
||||||
|
active={workspace.activeWindowId === workspace.map.id}
|
||||||
|
kind="map"
|
||||||
|
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
|
||||||
|
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
|
||||||
|
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
|
||||||
|
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
|
||||||
|
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
|
||||||
|
>
|
||||||
|
{mapContent}
|
||||||
|
{mapOverlay}
|
||||||
|
</WorkspaceWindow>
|
||||||
|
|
||||||
|
{workspace.analyses.map((window) => (
|
||||||
|
<WorkspaceWindow
|
||||||
|
key={window.id}
|
||||||
|
id={window.id}
|
||||||
|
title={window.title}
|
||||||
|
mode={window.mode}
|
||||||
|
rect={window.rect}
|
||||||
|
zIndex={window.zIndex}
|
||||||
|
active={workspace.activeWindowId === window.id}
|
||||||
|
kind="analysis"
|
||||||
|
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||||
|
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||||
|
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
|
||||||
|
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
|
||||||
|
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
|
||||||
|
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
|
||||||
|
>
|
||||||
|
<AnalysisDocumentView document={window.document} />
|
||||||
|
</WorkspaceWindow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="工作区窗口任务栏"
|
||||||
|
>
|
||||||
|
<TaskbarButton
|
||||||
|
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
|
||||||
|
minimized={workspace.map.mode === "minimized"}
|
||||||
|
icon={<MapIcon size={15} aria-hidden="true" />}
|
||||||
|
label="供水管网地图"
|
||||||
|
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
|
||||||
|
/>
|
||||||
|
{workspace.analyses.map((window) => (
|
||||||
|
<TaskbarButton
|
||||||
|
key={window.id}
|
||||||
|
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
|
||||||
|
minimized={window.mode === "minimized"}
|
||||||
|
icon={<Sparkles size={14} aria-hidden="true" />}
|
||||||
|
label={window.title}
|
||||||
|
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} 个临时分析窗</span>
|
||||||
|
</div>
|
||||||
|
{!desktop ? <span className="sr-only">移动端保持地图与抽屉工作台</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
|
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{icon}</span>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
MAX_ANALYSIS_WINDOWS,
|
||||||
|
closeAnalysisWindow,
|
||||||
|
createInitialWorkspaceState,
|
||||||
|
maximizeWorkspaceWindow,
|
||||||
|
minimizeWorkspaceWindow,
|
||||||
|
openAnalysisDocument,
|
||||||
|
resizeWorkspace,
|
||||||
|
restoreWorkspaceWindow,
|
||||||
|
updateWorkspaceWindowRect,
|
||||||
|
type AnalysisDocument
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
const bounds = { width: 1180, height: 760 };
|
||||||
|
|
||||||
|
function documentAt(index: number): AnalysisDocument {
|
||||||
|
return {
|
||||||
|
id: `analysis-${index}`,
|
||||||
|
title: `分析 ${index}`,
|
||||||
|
subtitle: "测试",
|
||||||
|
generatedAt: "10:40:00",
|
||||||
|
blocks: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace window lifecycle", () => {
|
||||||
|
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
|
||||||
|
const initial = createInitialWorkspaceState(bounds);
|
||||||
|
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
|
||||||
|
|
||||||
|
expect(opened.map.mode).toBe("minimized");
|
||||||
|
expect(opened.analyses).toHaveLength(1);
|
||||||
|
|
||||||
|
const closed = closeAnalysisWindow(opened, "analysis-1");
|
||||||
|
expect(closed.analyses).toHaveLength(0);
|
||||||
|
expect(closed.map.mode).toBe("docked");
|
||||||
|
expect(closed.activeWindowId).toBe("workspace-map");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports minimize, maximize and bounded restore", () => {
|
||||||
|
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
|
||||||
|
const minimized = minimizeWorkspaceWindow(opened, "analysis-1");
|
||||||
|
expect(minimized.analyses[0]?.mode).toBe("minimized");
|
||||||
|
|
||||||
|
const restored = restoreWorkspaceWindow(minimized, "analysis-1", bounds);
|
||||||
|
expect(restored.analyses[0]?.mode).toBe("floating");
|
||||||
|
|
||||||
|
const maximized = maximizeWorkspaceWindow(restored, "analysis-1");
|
||||||
|
expect(maximized.analyses[0]?.mode).toBe("maximized");
|
||||||
|
|
||||||
|
const resized = updateWorkspaceWindowRect(
|
||||||
|
maximized,
|
||||||
|
"analysis-1",
|
||||||
|
{ x: -100, y: -80, width: 4000, height: 3000 },
|
||||||
|
bounds
|
||||||
|
);
|
||||||
|
expect(resized.analyses[0]?.rect).toEqual({ x: 12, y: 12, width: 1156, height: 736 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps transient analysis windows without deleting an existing result", () => {
|
||||||
|
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce(
|
||||||
|
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||||
|
createInitialWorkspaceState(bounds)
|
||||||
|
);
|
||||||
|
|
||||||
|
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
|
||||||
|
expect(overflow).toBe(state);
|
||||||
|
expect(overflow.analyses).toHaveLength(MAX_ANALYSIS_WINDOWS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reclamps current and restore rectangles when the workspace shrinks", () => {
|
||||||
|
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
|
||||||
|
const moved = updateWorkspaceWindowRect(
|
||||||
|
opened,
|
||||||
|
"analysis-1",
|
||||||
|
{ x: 500, y: 260, width: 660, height: 480 },
|
||||||
|
bounds
|
||||||
|
);
|
||||||
|
|
||||||
|
const resized = resizeWorkspace(moved, { width: 800, height: 600 });
|
||||||
|
const analysis = resized.analyses[0];
|
||||||
|
expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 });
|
||||||
|
expect(analysis?.restoreRect).toEqual(analysis?.rect);
|
||||||
|
expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788);
|
||||||
|
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
export const MAX_ANALYSIS_WINDOWS = 6;
|
||||||
|
|
||||||
|
export type WorkspaceRect = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceBounds = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
|
||||||
|
|
||||||
|
export type AnalysisMetric = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
tone: "neutral" | "info" | "success" | "warning" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisChartSeries = {
|
||||||
|
name: string;
|
||||||
|
values: number[];
|
||||||
|
color: string;
|
||||||
|
type?: "line" | "bar";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisBlock =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "metric-grid";
|
||||||
|
title: string;
|
||||||
|
span: 12;
|
||||||
|
metrics: AnalysisMetric[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "chart";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
chartType: "line" | "bar";
|
||||||
|
categories: string[];
|
||||||
|
unit: string;
|
||||||
|
series: AnalysisChartSeries[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "process-flow";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
status: "complete" | "active" | "pending";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "data-table";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
columns: Array<{ key: string; label: string; numeric?: boolean }>;
|
||||||
|
rows: Array<Record<string, string | number>>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "narrative";
|
||||||
|
title: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisDocument = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
generatedAt: string;
|
||||||
|
blocks: AnalysisBlock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisWorkspaceWindow = {
|
||||||
|
id: string;
|
||||||
|
kind: "agent-analysis";
|
||||||
|
title: string;
|
||||||
|
mode: Exclude<WorkspaceWindowMode, "docked">;
|
||||||
|
rect: WorkspaceRect;
|
||||||
|
restoreRect: WorkspaceRect;
|
||||||
|
zIndex: number;
|
||||||
|
document: AnalysisDocument;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapWorkspaceWindow = {
|
||||||
|
id: "workspace-map";
|
||||||
|
kind: "map";
|
||||||
|
title: string;
|
||||||
|
mode: WorkspaceWindowMode;
|
||||||
|
rect: WorkspaceRect;
|
||||||
|
restoreRect: WorkspaceRect;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceState = {
|
||||||
|
map: MapWorkspaceWindow;
|
||||||
|
analyses: AnalysisWorkspaceWindow[];
|
||||||
|
activeWindowId: string;
|
||||||
|
nextZIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||||
|
|
||||||
|
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
|
||||||
|
const mapRect = getDefaultMapRect(bounds);
|
||||||
|
return {
|
||||||
|
map: {
|
||||||
|
id: "workspace-map",
|
||||||
|
kind: "map",
|
||||||
|
title: "供水管网地图",
|
||||||
|
mode: "docked",
|
||||||
|
rect: mapRect,
|
||||||
|
restoreRect: mapRect,
|
||||||
|
zIndex: 1
|
||||||
|
},
|
||||||
|
analyses: [],
|
||||||
|
activeWindowId: "workspace-map",
|
||||||
|
nextZIndex: 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAnalysisDocument(
|
||||||
|
state: WorkspaceState,
|
||||||
|
document: AnalysisDocument,
|
||||||
|
bounds: WorkspaceBounds
|
||||||
|
): WorkspaceState {
|
||||||
|
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
|
||||||
|
const window: AnalysisWorkspaceWindow = {
|
||||||
|
id: document.id,
|
||||||
|
kind: "agent-analysis",
|
||||||
|
title: document.title,
|
||||||
|
mode: "floating",
|
||||||
|
rect,
|
||||||
|
restoreRect: rect,
|
||||||
|
zIndex: state.nextZIndex,
|
||||||
|
document
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
map: { ...state.map, mode: "minimized" },
|
||||||
|
analyses: [...state.analyses, window],
|
||||||
|
activeWindowId: window.id,
|
||||||
|
nextZIndex: state.nextZIndex + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||||
|
const analyses = state.analyses.filter((window) => window.id !== windowId);
|
||||||
|
if (analyses.length === state.analyses.length) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analyses.length === 0) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
|
||||||
|
analyses,
|
||||||
|
activeWindowId: state.map.id,
|
||||||
|
nextZIndex: state.nextZIndex + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analyses,
|
||||||
|
activeWindowId: nextActive?.id ?? state.map.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||||
|
if (windowId === state.map.id) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
map: { ...state.map, zIndex: state.nextZIndex },
|
||||||
|
activeWindowId: windowId,
|
||||||
|
nextZIndex: state.nextZIndex + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.analyses.some((window) => window.id === windowId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analyses: state.analyses.map((window) =>
|
||||||
|
window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window
|
||||||
|
),
|
||||||
|
activeWindowId: windowId,
|
||||||
|
nextZIndex: state.nextZIndex + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||||
|
if (windowId === state.map.id) {
|
||||||
|
return { ...state, map: { ...state.map, mode: "minimized" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analyses: state.analyses.map((window) =>
|
||||||
|
window.id === windowId ? { ...window, mode: "minimized" } : window
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||||
|
if (windowId === state.map.id) {
|
||||||
|
return focusWorkspaceWindow(
|
||||||
|
{ ...state, map: { ...state.map, mode: "maximized" } },
|
||||||
|
windowId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return focusWorkspaceWindow(
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
analyses: state.analyses.map((window) =>
|
||||||
|
window.id === windowId ? { ...window, mode: "maximized" } : window
|
||||||
|
)
|
||||||
|
},
|
||||||
|
windowId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreWorkspaceWindow(
|
||||||
|
state: WorkspaceState,
|
||||||
|
windowId: string,
|
||||||
|
bounds: WorkspaceBounds
|
||||||
|
): WorkspaceState {
|
||||||
|
if (windowId === state.map.id) {
|
||||||
|
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map");
|
||||||
|
return focusWorkspaceWindow(
|
||||||
|
{ ...state, map: { ...state.map, mode: "floating", rect } },
|
||||||
|
windowId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return focusWorkspaceWindow(
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
analyses: state.analyses.map((window) =>
|
||||||
|
window.id === windowId
|
||||||
|
? {
|
||||||
|
...window,
|
||||||
|
mode: "floating",
|
||||||
|
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
||||||
|
}
|
||||||
|
: window
|
||||||
|
)
|
||||||
|
},
|
||||||
|
windowId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWorkspaceWindowRect(
|
||||||
|
state: WorkspaceState,
|
||||||
|
windowId: string,
|
||||||
|
rect: WorkspaceRect,
|
||||||
|
bounds: WorkspaceBounds
|
||||||
|
): WorkspaceState {
|
||||||
|
if (windowId === state.map.id) {
|
||||||
|
const nextRect = clampWorkspaceRect(rect, bounds, "map");
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analyses: state.analyses.map((window) => {
|
||||||
|
if (window.id !== windowId) return window;
|
||||||
|
const nextRect = clampWorkspaceRect(rect, bounds, "analysis");
|
||||||
|
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
map: {
|
||||||
|
...state.map,
|
||||||
|
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
|
||||||
|
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
|
||||||
|
},
|
||||||
|
analyses: state.analyses.map((window) => ({
|
||||||
|
...window,
|
||||||
|
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
|
||||||
|
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampWorkspaceRect(
|
||||||
|
rect: WorkspaceRect,
|
||||||
|
bounds: WorkspaceBounds,
|
||||||
|
kind: "map" | "analysis"
|
||||||
|
): WorkspaceRect {
|
||||||
|
const minimumWidth = kind === "map" ? 360 : 640;
|
||||||
|
const minimumHeight = kind === "map" ? 280 : 420;
|
||||||
|
const availableWidth = Math.max(280, bounds.width - 24);
|
||||||
|
const availableHeight = Math.max(240, bounds.height - 24);
|
||||||
|
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
|
||||||
|
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
|
||||||
|
return {
|
||||||
|
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
|
||||||
|
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
|
||||||
|
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
|
||||||
|
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
|
||||||
|
const cascade = (index % 5) * 28;
|
||||||
|
return clampWorkspaceRect(
|
||||||
|
{ x: 24 + cascade, y: 20 + cascade, width, height },
|
||||||
|
bounds,
|
||||||
|
"analysis"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
|
||||||
|
return clampWorkspaceRect(
|
||||||
|
{
|
||||||
|
x: Math.max(12, bounds.width * 0.36),
|
||||||
|
y: Math.max(12, bounds.height * 0.18),
|
||||||
|
width: bounds.width * 0.6,
|
||||||
|
height: bounds.height * 0.72
|
||||||
|
},
|
||||||
|
bounds,
|
||||||
|
"map"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Minus,
|
||||||
|
PanelTopClose,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
||||||
|
|
||||||
|
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||||
|
|
||||||
|
type WorkspaceWindowProps = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
mode: WorkspaceWindowMode;
|
||||||
|
rect: WorkspaceRect;
|
||||||
|
zIndex: number;
|
||||||
|
active: boolean;
|
||||||
|
kind: "map" | "analysis";
|
||||||
|
children: ReactNode;
|
||||||
|
onFocus: () => void;
|
||||||
|
onRectChange: (rect: WorkspaceRect) => void;
|
||||||
|
onMinimize: () => void;
|
||||||
|
onMaximize: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceWindow({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
mode,
|
||||||
|
rect,
|
||||||
|
zIndex,
|
||||||
|
active,
|
||||||
|
kind,
|
||||||
|
children,
|
||||||
|
onFocus,
|
||||||
|
onRectChange,
|
||||||
|
onMinimize,
|
||||||
|
onMaximize,
|
||||||
|
onRestore,
|
||||||
|
onClose
|
||||||
|
}: WorkspaceWindowProps) {
|
||||||
|
const rectRef = useRef(rect);
|
||||||
|
rectRef.current = rect;
|
||||||
|
|
||||||
|
const floating = mode === "floating";
|
||||||
|
const style = mode === "docked"
|
||||||
|
? { inset: 0, zIndex }
|
||||||
|
: mode === "maximized"
|
||||||
|
? { inset: 8, zIndex }
|
||||||
|
: { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex };
|
||||||
|
|
||||||
|
const beginPointerAction = (
|
||||||
|
event: ReactPointerEvent,
|
||||||
|
action: "move" | ResizeDirection
|
||||||
|
) => {
|
||||||
|
if (!floating || event.button !== 0) return;
|
||||||
|
if (event.target instanceof Element && event.target.closest("button")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onFocus();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startRect = rectRef.current;
|
||||||
|
|
||||||
|
const handleMove = (pointerEvent: PointerEvent) => {
|
||||||
|
const deltaX = pointerEvent.clientX - startX;
|
||||||
|
const deltaY = pointerEvent.clientY - startY;
|
||||||
|
onRectChange(resizeRect(startRect, action, deltaX, deltaY));
|
||||||
|
};
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener("pointermove", handleMove);
|
||||||
|
window.removeEventListener("pointerup", handleUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", handleMove);
|
||||||
|
window.addEventListener("pointerup", handleUp, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (!floating || !event.key.startsWith("Arrow")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.altKey ? 2 : 12;
|
||||||
|
const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
||||||
|
const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
||||||
|
onRectChange(
|
||||||
|
event.shiftKey
|
||||||
|
? { ...rect, width: rect.width + deltaX, height: rect.height + deltaY }
|
||||||
|
: { ...rect, x: rect.x + deltaX, y: rect.y + deltaY }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
aria-label={title}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900",
|
||||||
|
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
||||||
|
mode === "docked" ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||||
|
active ? "lg:ring-1 lg:ring-blue-500/40" : "lg:ring-1 lg:ring-slate-300/90"
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onPointerDownCapture={onFocus}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
|
||||||
|
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
|
||||||
|
floating && "cursor-move"
|
||||||
|
)}
|
||||||
|
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
|
||||||
|
onKeyDown={handleTitleKeyDown}
|
||||||
|
onPointerDown={(event) => beginPointerAction(event, "move")}
|
||||||
|
>
|
||||||
|
<span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-md", kind === "map" ? "bg-blue-600 text-white" : "bg-slate-900 text-white")}>
|
||||||
|
{kind === "map" ? <PanelTopClose size={15} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900">{title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center">
|
||||||
|
<WindowButton label={`最小化${title}`} onClick={onMinimize}><Minus size={16} aria-hidden="true" /></WindowButton>
|
||||||
|
{mode === "maximized" || mode === "docked" ? (
|
||||||
|
<WindowButton label={`浮动${title}`} onClick={onRestore}><Minimize2 size={15} aria-hidden="true" /></WindowButton>
|
||||||
|
) : (
|
||||||
|
<WindowButton label={`最大化${title}`} onClick={onMaximize}><Maximize2 size={15} aria-hidden="true" /></WindowButton>
|
||||||
|
)}
|
||||||
|
{onClose ? <WindowButton label={`关闭并销毁${title}`} danger onClick={onClose}><X size={16} aria-hidden="true" /></WindowButton> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||||
|
{floating ? RESIZE_DIRECTIONS.map((direction) => (
|
||||||
|
<span
|
||||||
|
key={direction}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("hidden lg:block", resizeHandleClass(direction))}
|
||||||
|
onPointerDown={(event) => beginPointerAction(event, direction)}
|
||||||
|
/>
|
||||||
|
)) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||||
|
|
||||||
|
function WindowButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
"grid h-10 w-10 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
|
danger ? "hover:bg-rose-100 hover:text-rose-700" : "hover:bg-white hover:text-blue-700"
|
||||||
|
)}
|
||||||
|
onClick={(event) => { event.stopPropagation(); onClick(); }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
|
||||||
|
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
|
||||||
|
const west = direction.includes("w");
|
||||||
|
const east = direction.includes("e");
|
||||||
|
const north = direction.includes("n");
|
||||||
|
const south = direction.includes("s");
|
||||||
|
return {
|
||||||
|
x: west ? rect.x + deltaX : rect.x,
|
||||||
|
y: north ? rect.y + deltaY : rect.y,
|
||||||
|
width: rect.width + (east ? deltaX : west ? -deltaX : 0),
|
||||||
|
height: rect.height + (south ? deltaY : north ? -deltaY : 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeHandleClass(direction: ResizeDirection) {
|
||||||
|
const shared = "absolute z-20 block touch-none";
|
||||||
|
const classes: Record<ResizeDirection, string> = {
|
||||||
|
n: "left-2 right-2 top-0 h-2 cursor-n-resize",
|
||||||
|
ne: "right-0 top-0 h-3 w-3 cursor-ne-resize",
|
||||||
|
e: "bottom-2 right-0 top-2 w-2 cursor-e-resize",
|
||||||
|
se: "bottom-0 right-0 h-3 w-3 cursor-se-resize",
|
||||||
|
s: "bottom-0 left-2 right-2 h-2 cursor-s-resize",
|
||||||
|
sw: "bottom-0 left-0 h-3 w-3 cursor-sw-resize",
|
||||||
|
w: "bottom-2 left-0 top-2 w-2 cursor-w-resize",
|
||||||
|
nw: "left-0 top-0 h-3 w-3 cursor-nw-resize"
|
||||||
|
};
|
||||||
|
return `${shared} ${classes[direction]}`;
|
||||||
|
}
|
||||||
@@ -899,6 +899,49 @@ 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-main-frame .agent-panel-shell,
|
||||||
|
.workbench-main-frame .scheduled-feed-panel-shell,
|
||||||
|
.workbench-main-frame .surface-dock,
|
||||||
|
.workbench-main-frame .surface-control,
|
||||||
|
.workbench-main-frame .surface-reading {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-main-frame .agent-panel-shell {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f7f9fb;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-main-frame .scheduled-feed-panel-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f7f9fb;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-main-frame .workspace-window-titlebar {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.workbench-main-frame [aria-label="工作台导航"],
|
||||||
|
.workbench-main-frame [aria-label="工作区窗口任务栏"],
|
||||||
|
.workbench-main-frame .workspace-window-titlebar {
|
||||||
|
border-color: CanvasText;
|
||||||
|
background: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.agent-ready-orbit,
|
.agent-ready-orbit,
|
||||||
.agent-ready-core {
|
.agent-ready-core {
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
|||||||
@@ -31,35 +31,32 @@ async function waitForAnimations(locator: Locator) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
|
test("desktop Agent lives in the solid Main Frame navigation pane", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const topBar = page.locator("header.acrylic-navigation");
|
const topBar = page.locator("header.acrylic-navigation");
|
||||||
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
const agentConversation = agentPanel.getByRole("log");
|
||||||
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
||||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
const agentControls = agentPanel.locator(".agent-panel-band").last();
|
||||||
|
|
||||||
await expect(topBar).toBeVisible();
|
await expect(topBar).toBeVisible();
|
||||||
|
await expect(navigation).toBeVisible();
|
||||||
await expect(agentPanel).toBeVisible();
|
await expect(agentPanel).toBeVisible();
|
||||||
await expect(agentHeader).toBeVisible();
|
await expect(agentHeader).toBeVisible();
|
||||||
await expect(agentComposer).toBeVisible();
|
await expect(agentControls).toBeVisible();
|
||||||
await waitForAnimations(agentPanel);
|
await waitForAnimations(agentPanel);
|
||||||
|
|
||||||
await expectAcrylicSurface(topBar);
|
await expectAcrylicSurface(topBar);
|
||||||
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
|
await expect(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)");
|
||||||
await expectIntegratedHeader(agentHeader);
|
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||||
await expectContextualAcrylicSurface(agentComposer, {
|
await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)");
|
||||||
backdropFilter: "blur(",
|
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
|
||||||
maximumAlpha: 0.9,
|
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||||
minimumAlpha: 0.84
|
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||||
});
|
await expect(agentControls).toHaveCSS("border-top-width", "1px");
|
||||||
await expectLightweightMaterialContext(agentConversation);
|
|
||||||
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
|
|
||||||
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
|
|
||||||
await expect(
|
await expect(
|
||||||
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||||
).toHaveCSS("color", "rgb(51, 65, 85)");
|
).toHaveCSS("color", "rgb(51, 65, 85)");
|
||||||
@@ -69,66 +66,30 @@ test("desktop Agent layers floating controls inside one acrylic panel", async ({
|
|||||||
const panelBox = await agentPanel.boundingBox();
|
const panelBox = await agentPanel.boundingBox();
|
||||||
const conversationBox = await agentConversation.boundingBox();
|
const conversationBox = await agentConversation.boundingBox();
|
||||||
const scrollBox = await agentConversationScroll.boundingBox();
|
const scrollBox = await agentConversationScroll.boundingBox();
|
||||||
const composerBox = await agentComposer.boundingBox();
|
|
||||||
expect(panelBox).not.toBeNull();
|
expect(panelBox).not.toBeNull();
|
||||||
expect(conversationBox).not.toBeNull();
|
expect(conversationBox).not.toBeNull();
|
||||||
expect(scrollBox).not.toBeNull();
|
expect(scrollBox).not.toBeNull();
|
||||||
expect(composerBox).not.toBeNull();
|
expect(panelBox!.x).toBe(48);
|
||||||
|
expect(panelBox!.width).toBe(319);
|
||||||
expect(
|
expect(
|
||||||
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
|
Math.abs(conversationBox!.x - panelBox!.x)
|
||||||
).toBeLessThanOrEqual(1);
|
|
||||||
expect(
|
|
||||||
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
|
|
||||||
).toBeLessThanOrEqual(1);
|
|
||||||
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
|
|
||||||
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
|
|
||||||
expect(
|
|
||||||
Math.abs(
|
|
||||||
composerBox!.x -
|
|
||||||
panelBox!.x -
|
|
||||||
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
|
|
||||||
)
|
|
||||||
).toBeLessThanOrEqual(1);
|
).toBeLessThanOrEqual(1);
|
||||||
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
||||||
|
|
||||||
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
|
const beforeFocus = await getSurfacePresentation(agentPanel);
|
||||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
|
.poll(() => getSurfacePresentation(agentPanel))
|
||||||
.toEqual(beforeFocus);
|
.toEqual(beforeFocus);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectIntegratedHeader(header: Locator) {
|
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
|
||||||
const composition = await header.evaluate((element) => {
|
|
||||||
const style = getComputedStyle(element);
|
|
||||||
|
|
||||||
return {
|
|
||||||
backdropFilter: style.backdropFilter,
|
|
||||||
backgroundColor: style.backgroundColor,
|
|
||||||
borderTopWidth: style.borderTopWidth,
|
|
||||||
borderRadius: style.borderRadius
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(composition.backdropFilter).toBe("none");
|
|
||||||
expect(getAlpha(composition.backgroundColor)).toBe(0);
|
|
||||||
expect(composition.borderTopWidth).toBe("0px");
|
|
||||||
expect(composition.borderRadius).toBe("0px");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("desktop Agent header expands into one attached acrylic history surface", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
|
||||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
|
||||||
const historyButton = agentPanel.getByRole("button", {
|
const historyButton = agentPanel.getByRole("button", {
|
||||||
name: "打开 Agent 历史记录"
|
name: "打开 Agent 历史记录"
|
||||||
});
|
});
|
||||||
const headerBefore = await agentHeader.boundingBox();
|
|
||||||
const conversationBefore = await agentConversation.boundingBox();
|
|
||||||
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
|
|
||||||
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
|
|
||||||
|
|
||||||
await historyButton.click();
|
await historyButton.click();
|
||||||
|
|
||||||
@@ -136,51 +97,7 @@ test("desktop Agent header expands into one attached acrylic history surface", a
|
|||||||
await expect(history).toBeVisible();
|
await expect(history).toBeVisible();
|
||||||
await waitForAnimations(history);
|
await waitForAnimations(history);
|
||||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||||
await page.mouse.move(720, 320);
|
|
||||||
await expectAttachedHistoryExtension(history);
|
|
||||||
await expectTransparentExpandedHeader(agentHeader, agentPanel);
|
|
||||||
await expectUnifiedHistoryStructure(history);
|
await expectUnifiedHistoryStructure(history);
|
||||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
|
||||||
await expect
|
|
||||||
.poll(() => getSurfacePresentation(historyButton))
|
|
||||||
.toEqual(historyButtonPresentationBefore);
|
|
||||||
|
|
||||||
const sessionTrigger = history.locator(".agent-history-session-trigger").first();
|
|
||||||
const sessionTriggerBox = await sessionTrigger.boundingBox();
|
|
||||||
const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox();
|
|
||||||
expect(sessionTriggerBox).not.toBeNull();
|
|
||||||
expect(historyHeadingBox).not.toBeNull();
|
|
||||||
await page.mouse.move(
|
|
||||||
sessionTriggerBox!.x + sessionTriggerBox!.width / 2,
|
|
||||||
sessionTriggerBox!.y + sessionTriggerBox!.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.down();
|
|
||||||
await expect(sessionTrigger).toHaveCSS("outline-style", "none");
|
|
||||||
await page.mouse.move(
|
|
||||||
historyHeadingBox!.x + historyHeadingBox!.width / 2,
|
|
||||||
historyHeadingBox!.y + historyHeadingBox!.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const headerBox = await agentHeader.boundingBox();
|
|
||||||
const agentPanelBox = await agentPanel.boundingBox();
|
|
||||||
const historyBox = await history.boundingBox();
|
|
||||||
const conversationAfter = await agentConversation.boundingBox();
|
|
||||||
expect(headerBefore).not.toBeNull();
|
|
||||||
expect(headerBox).not.toBeNull();
|
|
||||||
expect(agentPanelBox).not.toBeNull();
|
|
||||||
expect(historyBox).not.toBeNull();
|
|
||||||
expect(conversationBefore).not.toBeNull();
|
|
||||||
expect(conversationAfter).not.toBeNull();
|
|
||||||
expectRoundedValue(headerBox!.y, headerBefore!.y);
|
|
||||||
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
|
|
||||||
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
|
|
||||||
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
|
|
||||||
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
|
|
||||||
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
|
|
||||||
expectRoundedValue(historyBox!.x, headerBox!.x);
|
|
||||||
expectRoundedValue(historyBox!.width, headerBox!.width);
|
|
||||||
expectRoundedBox(conversationAfter!, conversationBefore!);
|
|
||||||
|
|
||||||
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
||||||
const renameInput = history.locator("input").first();
|
const renameInput = history.locator("input").first();
|
||||||
@@ -197,12 +114,6 @@ test("desktop Agent header expands into one attached acrylic history surface", a
|
|||||||
await expect(history).toBeHidden();
|
await expect(history).toBeHidden();
|
||||||
await expect(historyButton).toBeFocused();
|
await expect(historyButton).toBeFocused();
|
||||||
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
|
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
|
||||||
|
|
||||||
await historyButton.click();
|
|
||||||
await expect(history).toBeVisible();
|
|
||||||
await page.mouse.click(720, 320);
|
|
||||||
await expect(history).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
|
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
|
||||||
@@ -268,15 +179,16 @@ test("mobile Agent mirrors the desktop floating history surface", async ({ page
|
|||||||
await expect(historyButton).toBeFocused();
|
await expect(historyButton).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
|
test("Main Frame navigation respects forced color mode", async ({ page }) => {
|
||||||
await page.emulateMedia({ forcedColors: "active" });
|
await page.emulateMedia({ forcedColors: "active" });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const composer = page.locator(".agent-panel-composer");
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
await expect(composer).toBeVisible();
|
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" });
|
||||||
await expect(composer).toHaveCSS("backdrop-filter", "none");
|
await expect(navigation).toBeVisible();
|
||||||
await expect(composer).toHaveCSS("background-image", "none");
|
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||||
await expect(composer).toHaveCSS("box-shadow", "none");
|
await expect(navigation).toHaveCSS("box-shadow", "none");
|
||||||
|
await expect(taskbar).toHaveCSS("box-shadow", "none");
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
||||||
|
|||||||
@@ -1,45 +1,51 @@
|
|||||||
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("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
|
test("unified workbench navigation collapses and restores the main workspace", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
const workspace = page.locator("#main-workspace");
|
||||||
await expect(panel).toBeVisible();
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
await expect(resizeHandle).toBeVisible();
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368);
|
||||||
|
|
||||||
const handleBox = await resizeHandle.boundingBox();
|
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
||||||
expect(handleBox).not.toBeNull();
|
await expect(navigation).toHaveCSS("width", "48px");
|
||||||
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
|
||||||
|
|
||||||
await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
|
await page.getByRole("button", { name: "展开工作台导航" }).click();
|
||||||
await page.mouse.down();
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368);
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
|
||||||
|
|
||||||
await resizeHandle.focus();
|
|
||||||
await page.keyboard.press("Home");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
|
|
||||||
await page.keyboard.press("ArrowRight");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
|
|
||||||
await page.keyboard.press("End");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
test("unified navigation switches between Agent and condition panes", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
const mainFrame = page.getByTestId("workbench-main-frame");
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
|
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
||||||
|
await prompt.fill("保留尚未发送的诊断问题");
|
||||||
|
await mainFrame.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
|
await expect(conditionPanel).toBeVisible();
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeHidden();
|
||||||
|
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
|
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
|
||||||
|
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
|
||||||
|
|
||||||
const rail = page.locator('aside[aria-label="Agent 折叠栏"]');
|
await mainFrame.getByRole("button", { name: "Agent", exact: true }).click();
|
||||||
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ });
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
await expect(rail).toBeVisible();
|
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||||
await expect(rail).toHaveCSS("width", "72px");
|
|
||||||
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
|
|
||||||
|
|
||||||
await expandButton.click();
|
const topBarConditionToggle = page
|
||||||
|
.locator("header.acrylic-navigation")
|
||||||
|
.getByRole("button", { name: "工况任务", exact: true });
|
||||||
|
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
||||||
|
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "48px");
|
||||||
|
await topBarConditionToggle.click();
|
||||||
|
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "368px");
|
||||||
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
|
||||||
|
await topBarConditionToggle.click();
|
||||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockAgentApi } from "./support/mock-agent-api";
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
const EMPTY_RASTER_TILE = Buffer.from(
|
const EMPTY_RASTER_TILE = Buffer.from(
|
||||||
@@ -6,24 +6,19 @@ const EMPTY_RASTER_TILE = Buffer.from(
|
|||||||
"base64"
|
"base64"
|
||||||
);
|
);
|
||||||
|
|
||||||
test.describe("neutral blue mist workbench", () => {
|
test.describe("Main Frame workbench", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await prepareDeterministicWorkbench(page);
|
await prepareDeterministicWorkbench(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop light basemap keeps both floating panels legible", async ({ page }) => {
|
test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
|
|
||||||
await expectDesktopFloatingGeometry(page);
|
await expectDesktopMainFrameGeometry(page);
|
||||||
await expectAcrylicAlphas(page, {
|
await expectSolidMainFrameMaterials(page);
|
||||||
navigation: 0.64,
|
|
||||||
agentPanel: 0.82,
|
|
||||||
conditionPanel: 0.56,
|
|
||||||
control: 0.74
|
|
||||||
});
|
|
||||||
await expectLoadedChineseFont(page);
|
await expectLoadedChineseFont(page);
|
||||||
await expectBodyContrast(page);
|
await expectBodyContrast(page);
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
||||||
@@ -32,42 +27,35 @@ test.describe("neutral blue mist workbench", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => {
|
test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
await selectBasemap(page, "影像");
|
await selectBasemap(page, "影像");
|
||||||
|
|
||||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||||
await expectDesktopFloatingGeometry(page);
|
await expectDesktopMainFrameGeometry(page);
|
||||||
await expectAcrylicAlphas(page, {
|
await expectSolidMainFrameMaterials(page);
|
||||||
navigation: 0.8,
|
|
||||||
agentPanel: 0.88,
|
|
||||||
conditionPanel: 0.72,
|
|
||||||
control: 0.86
|
|
||||||
});
|
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
maxDiffPixelRatio: 0.01
|
maxDiffPixelRatio: 0.01
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => {
|
test("wide desktop expands the map document when navigation is collapsed", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
|
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
const workspace = page.locator("#main-workspace");
|
||||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
const expandedWorkspace = await workspace.boundingBox();
|
||||||
|
expect(expandedWorkspace).not.toBeNull();
|
||||||
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
|
|
||||||
await resizeHandle.focus();
|
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
||||||
await page.keyboard.press("End");
|
await expect(navigation).toHaveCSS("width", "48px");
|
||||||
await expect(agentPanel).toHaveCSS("width", "720px");
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
|
||||||
await expectMapCorridor(agentPanel, conditionPanel, 680);
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872);
|
||||||
|
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
|
||||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
|
||||||
await expect(conditionPanel).toHaveCSS("width", "960px");
|
|
||||||
await expect(agentPanel).toHaveCSS("width", "628px");
|
|
||||||
await expectMapCorridor(agentPanel, conditionPanel, 256);
|
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
maxDiffPixelRatio: 0.01
|
maxDiffPixelRatio: 0.01
|
||||||
@@ -201,41 +189,59 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectDesktopFloatingGeometry(page: Page) {
|
async function expectDesktopMainFrameGeometry(page: Page) {
|
||||||
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
|
const workspace = page.locator("#main-workspace");
|
||||||
|
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" });
|
||||||
|
const mapWindow = page.locator("#workspace-map");
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
await expect(mapWindow).toHaveCSS("border-radius", "0px");
|
||||||
|
|
||||||
await expect(agentPanel).toHaveCSS("width", "500px");
|
|
||||||
await expect(conditionPanel).toHaveCSS("width", "432px");
|
|
||||||
await expect(agentPanel).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentHeader).toHaveCSS("border-radius", "0px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
|
|
||||||
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
|
|
||||||
|
|
||||||
|
const navigationBox = await navigation.boundingBox();
|
||||||
|
const workspaceBox = await workspace.boundingBox();
|
||||||
|
const taskbarBox = await taskbar.boundingBox();
|
||||||
|
const mapBox = await mapWindow.boundingBox();
|
||||||
const agentBox = await agentPanel.boundingBox();
|
const agentBox = await agentPanel.boundingBox();
|
||||||
const conditionBox = await conditionPanel.boundingBox();
|
expect(navigationBox).not.toBeNull();
|
||||||
|
expect(workspaceBox).not.toBeNull();
|
||||||
|
expect(taskbarBox).not.toBeNull();
|
||||||
|
expect(mapBox).not.toBeNull();
|
||||||
expect(agentBox).not.toBeNull();
|
expect(agentBox).not.toBeNull();
|
||||||
expect(conditionBox).not.toBeNull();
|
expect(navigationBox!.x).toBe(0);
|
||||||
expect(agentBox!.x).toBe(12);
|
expect(navigationBox!.y).toBe(56);
|
||||||
expect(agentBox!.y).toBe(96);
|
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
|
||||||
expect(agentBox!.y + agentBox!.height).toBe(884);
|
expect(workspaceBox!.y).toBe(navigationBox!.y);
|
||||||
expect(conditionBox!.x).toBe(944);
|
expect(taskbarBox!.x).toBe(workspaceBox!.x);
|
||||||
expect(conditionBox!.y).toBe(96);
|
expect(taskbarBox!.y).toBe(workspaceBox!.y + workspaceBox!.height);
|
||||||
|
expect(mapBox).toEqual(workspaceBox);
|
||||||
|
expect(agentBox!.x).toBeGreaterThanOrEqual(48);
|
||||||
|
expect(agentBox!.x).toBeLessThanOrEqual(49);
|
||||||
|
expect(
|
||||||
|
Math.abs(agentBox!.x + agentBox!.width - (navigationBox!.x + navigationBox!.width))
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectMapCorridor(
|
async function expectSolidMainFrameMaterials(page: Page) {
|
||||||
agentPanel: Locator,
|
const materials = await page.evaluate(() => {
|
||||||
conditionPanel: Locator,
|
const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!);
|
||||||
minimumWidth: number
|
const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
|
||||||
) {
|
return {
|
||||||
const agentBox = await agentPanel.boundingBox();
|
navigationBackdrop: navigation.backdropFilter,
|
||||||
const conditionBox = await conditionPanel.boundingBox();
|
navigationBackground: navigation.backgroundColor,
|
||||||
expect(agentBox).not.toBeNull();
|
agentBackdrop: agent.backdropFilter,
|
||||||
expect(conditionBox).not.toBeNull();
|
agentBackground: agent.backgroundColor,
|
||||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
|
agentShadow: agent.boxShadow
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(materials).toEqual({
|
||||||
|
navigationBackdrop: "none",
|
||||||
|
navigationBackground: "rgb(237, 241, 245)",
|
||||||
|
agentBackdrop: "none",
|
||||||
|
agentBackground: "rgb(247, 249, 251)",
|
||||||
|
agentShadow: "none"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectLoadedChineseFont(page: Page) {
|
async function expectLoadedChineseFont(page: Page) {
|
||||||
@@ -246,39 +252,6 @@ async function expectLoadedChineseFont(page: Page) {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectAcrylicAlphas(
|
|
||||||
page: Page,
|
|
||||||
expected: {
|
|
||||||
navigation: number;
|
|
||||||
agentPanel: number;
|
|
||||||
conditionPanel: number;
|
|
||||||
control: number;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const alphas = await page.evaluate(() => {
|
|
||||||
return {
|
|
||||||
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
|
|
||||||
agentPanel: alphaOf(document.querySelector('aside[aria-label="Agent 命令面板"]')),
|
|
||||||
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
|
|
||||||
control: alphaOf(document.querySelector(".acrylic-control"))
|
|
||||||
};
|
|
||||||
|
|
||||||
function alphaOf(element: Element | null) {
|
|
||||||
if (!element) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const color = getComputedStyle(element).backgroundColor;
|
|
||||||
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
|
|
||||||
return channels[3] ?? 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
|
|
||||||
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
|
|
||||||
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
|
|
||||||
expect(alphas.control).toBeCloseTo(expected.control, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectBodyContrast(page: Page) {
|
async function expectBodyContrast(page: Page) {
|
||||||
const contrast = await page
|
const contrast = await page
|
||||||
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 199 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 219 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 174 KiB |
@@ -0,0 +1,114 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
|
test("temporary Agent analysis windows replace and restore the map workspace", async ({ page }) => {
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const frame = page.getByTestId("workbench-main-frame");
|
||||||
|
const mapWindow = page.locator("#workspace-map");
|
||||||
|
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
|
||||||
|
|
||||||
|
await expect(frame).toBeVisible();
|
||||||
|
await expect(mapWindow).toBeVisible();
|
||||||
|
await expect(testButton).toBeVisible();
|
||||||
|
|
||||||
|
await testButton.click();
|
||||||
|
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible();
|
||||||
|
await expect(mapWindow).toBeHidden();
|
||||||
|
|
||||||
|
await testButton.click();
|
||||||
|
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "最小化供水服务分区平衡分析" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "恢复供水服务分区平衡分析" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "恢复供水服务分区平衡分析" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "关闭并销毁供水服务分区平衡分析" }).click();
|
||||||
|
await page.getByRole("button", { name: "关闭并销毁北辰分区压力异常诊断" }).click();
|
||||||
|
await expect(mapWindow).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the map document floats and minimizes to its taskbar entry", async ({ page }) => {
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const workspace = page.locator("#main-workspace");
|
||||||
|
const mapWindow = page.locator("#workspace-map");
|
||||||
|
const dockedBox = await workspace.boundingBox();
|
||||||
|
expect(dockedBox).not.toBeNull();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "浮动供水管网地图" }).click();
|
||||||
|
const floatingBox = await mapWindow.boundingBox();
|
||||||
|
expect(floatingBox).not.toBeNull();
|
||||||
|
expect(floatingBox!.width).toBeLessThan(dockedBox!.width);
|
||||||
|
expect(floatingBox!.height).toBeLessThan(dockedBox!.height);
|
||||||
|
await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "最小化供水管网地图" }).click();
|
||||||
|
await expect(mapWindow).toBeHidden();
|
||||||
|
const restoreTask = page.getByRole("button", { name: "恢复供水管网地图" });
|
||||||
|
await expect(restoreTask).toBeVisible();
|
||||||
|
|
||||||
|
await restoreTask.click();
|
||||||
|
await expect(mapWindow).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "最大化供水管网地图" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the test control cycles through three trusted document profiles", async ({ page }) => {
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
|
||||||
|
|
||||||
|
await testButton.click();
|
||||||
|
await testButton.click();
|
||||||
|
await testButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeAttached();
|
||||||
|
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeAttached();
|
||||||
|
await expect(page.getByRole("heading", { name: "低压事件调度处置流程", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("3 / 6 个临时分析窗")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("temporary documents survive a mobile breakpoint round trip", async ({ page }) => {
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
||||||
|
|
||||||
|
const analysisHeading = page.locator("h2", { hasText: "北辰分区压力异常诊断" });
|
||||||
|
await expect(analysisHeading).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||||
|
await expect(analysisHeading).toBeAttached();
|
||||||
|
await expect(analysisHeading).toBeHidden();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await expect(analysisHeading).toBeVisible();
|
||||||
|
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function prepareMainFrame(page: Page) {
|
||||||
|
await mockAgentApi(page);
|
||||||
|
await page.route("**/runtime-config.js", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/javascript",
|
||||||
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
|
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||||
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
|
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||||
|
TJWATER_ENABLE_MSW: "false"
|
||||||
|
};`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("https://workspace-map.invalid/**", async (route) => {
|
||||||
|
await route.fulfill({ status: 204 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user