fix: improve mobile workbench controls
This commit is contained in:
+6
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { MapToaster } from "@/features/map/core/components/notice";
|
import { MapToaster } from "@/features/map/core/components/notice";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
@@ -9,6 +9,11 @@ export const metadata: Metadata = {
|
|||||||
description: "Agent 编排型沉浸式排水管网业务工作台"
|
description: "Agent 编排型沉浸式排水管网业务工作台"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children
|
children
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
|||||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||||
|
|
||||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||||
"top-center": "top-[84px]",
|
"top-center": "!left-1/2 !right-auto top-[84px] [translate:-50%_0]",
|
||||||
"top-right": "top-[84px] right-[72px]",
|
"top-right": "top-[84px] right-[72px]",
|
||||||
"bottom-left": "bottom-12 left-4",
|
"bottom-left": "bottom-12 left-4",
|
||||||
"bottom-right": "bottom-12 right-4"
|
"bottom-right": "bottom-12 right-4"
|
||||||
@@ -120,7 +120,7 @@ export function showMapNotice(options: MapNoticeOptions) {
|
|||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
className: cn("!fixed !z-[60]", positionClassNames[position])
|
className: cn("!fixed !z-[60] !w-[min(420px,calc(100vw-24px))]", positionClassNames[position])
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
|||||||
<div
|
<div
|
||||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
toneClassNames[tone]
|
toneClassNames[tone]
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
|
import {
|
||||||
|
compactHeaderTextClassName,
|
||||||
|
headerControlButtonClassName,
|
||||||
|
headerControlIconClassName
|
||||||
|
} from "./workbench-top-bar-styles";
|
||||||
|
|
||||||
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||||
active: "运行中",
|
active: "运行中",
|
||||||
@@ -50,11 +55,6 @@ const menuSurfaceClassName = cn(
|
|||||||
);
|
);
|
||||||
const menuReadableRowClassName =
|
const menuReadableRowClassName =
|
||||||
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
||||||
const headerControlButtonClassName =
|
|
||||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
|
||||||
const headerControlIconClassName =
|
|
||||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
|
||||||
|
|
||||||
export function ScenarioMenu({
|
export function ScenarioMenu({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -87,8 +87,8 @@ export function ScenarioMenu({
|
|||||||
<span className={headerControlIconClassName}>
|
<span className={headerControlIconClassName}>
|
||||||
<SlidersHorizontal size={14} aria-hidden="true" />
|
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">场景 · {activeScenarioName}</span>
|
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
|
||||||
<ChevronDown size={14} className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600" aria-hidden="true" />
|
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
||||||
@@ -177,7 +177,7 @@ export function AlertMenu({
|
|||||||
>
|
>
|
||||||
<Bell size={14} aria-hidden="true" />
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className={compact ? "sr-only" : undefined}>异常处置</span>
|
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||||
{alerts.length}
|
{alerts.length}
|
||||||
@@ -282,8 +282,8 @@ export function UserMenu({
|
|||||||
<span className={headerControlIconClassName}>
|
<span className={headerControlIconClassName}>
|
||||||
<UserRound size={14} aria-hidden="true" />
|
<UserRound size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden text-sm font-semibold md:inline">{user.name}</span>
|
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
|
||||||
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 sm:block" aria-hidden="true" />
|
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const headerControlButtonClassName =
|
||||||
|
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||||
|
|
||||||
|
export const headerControlIconClassName =
|
||||||
|
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||||
|
|
||||||
|
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||||
|
import {
|
||||||
|
compactHeaderTextClassName,
|
||||||
|
headerControlButtonClassName,
|
||||||
|
headerControlIconClassName
|
||||||
|
} from "./workbench-top-bar-styles";
|
||||||
|
|
||||||
export type WorkbenchTopBarProps = {
|
export type WorkbenchTopBarProps = {
|
||||||
dataTime: string;
|
dataTime: string;
|
||||||
@@ -45,11 +50,6 @@ export type WorkbenchTopBarProps = {
|
|||||||
onExportConfig: () => void;
|
onExportConfig: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerControlButtonClassName =
|
|
||||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
|
||||||
const headerControlIconClassName =
|
|
||||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
|
||||||
|
|
||||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||||
|
|
||||||
export function WorkbenchTopBar({
|
export function WorkbenchTopBar({
|
||||||
@@ -96,7 +96,7 @@ export function WorkbenchTopBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden min-w-0 items-center gap-3 text-sm text-slate-700 lg:flex xl:gap-4">
|
<div className="hidden min-w-0 items-center gap-2 text-sm text-slate-700 lg:flex 2xl:gap-4">
|
||||||
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
||||||
<HeaderDivider />
|
<HeaderDivider />
|
||||||
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
||||||
@@ -144,7 +144,7 @@ export function WorkbenchTopBar({
|
|||||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
||||||
>
|
>
|
||||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
||||||
<span>Dev</span>
|
<span className={compactHeaderTextClassName}>Dev</span>
|
||||||
</button>
|
</button>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@@ -209,7 +209,7 @@ function TaskTickerToggle({
|
|||||||
>
|
>
|
||||||
<Icon size={14} aria-hidden="true" />
|
<Icon size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span>任务浮条</span>
|
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ function ConditionFeedToggle({
|
|||||||
>
|
>
|
||||||
<CalendarClock size={14} aria-hidden="true" />
|
<CalendarClock size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span>工况任务</span>
|
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -249,8 +249,8 @@ function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label:
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||||
<span className="text-xs font-medium text-slate-500">{label}</span>
|
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||||
<span className="truncate text-sm font-semibold text-slate-800">{value}</span>
|
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type PointerEvent as ReactPointerEvent
|
type PointerEvent as ReactPointerEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
AgentCollapsedRail,
|
AgentCollapsedRail,
|
||||||
@@ -220,6 +219,15 @@ export function MapWorkbenchPage() {
|
|||||||
onUiEnvelope: handleAgentUiEnvelope,
|
onUiEnvelope: handleAgentUiEnvelope,
|
||||||
onFrontendAction: handleFrontendAction
|
onFrontendAction: handleFrontendAction
|
||||||
});
|
});
|
||||||
|
function openAgentPanelForViewport() {
|
||||||
|
if (isLargeScreen) {
|
||||||
|
agent.expandPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.openMobilePanel();
|
||||||
|
}
|
||||||
|
|
||||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||||
|
|
||||||
@@ -488,7 +496,7 @@ export function MapWorkbenchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.expandPanel();
|
openAgentPanelForViewport();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.submitPrompt(
|
await agent.submitPrompt(
|
||||||
@@ -931,7 +939,7 @@ export function MapWorkbenchPage() {
|
|||||||
onExpandedChange={setConditionFeedExpanded}
|
onExpandedChange={setConditionFeedExpanded}
|
||||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||||
onSelectedConditionChange={setSelectedConditionId}
|
onSelectedConditionChange={setSelectedConditionId}
|
||||||
onExpandAgent={agent.expandPanel}
|
onExpandAgent={openAgentPanelForViewport}
|
||||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||||
onSubmitPrompt={agent.submitPrompt}
|
onSubmitPrompt={agent.submitPrompt}
|
||||||
/>
|
/>
|
||||||
@@ -963,14 +971,14 @@ export function MapWorkbenchPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileAgentToggle
|
{!agent.mobileOpen ? (
|
||||||
open={agent.mobileOpen}
|
<MobileAgentToggle
|
||||||
personaState={agent.personaState}
|
personaState={agent.personaState}
|
||||||
statusLabel={agent.statusLabel}
|
statusLabel={agent.statusLabel}
|
||||||
streaming={agent.streaming}
|
streaming={agent.streaming}
|
||||||
onOpen={agent.openMobilePanel}
|
onOpen={agent.openMobilePanel}
|
||||||
onClose={agent.closeMobilePanel}
|
/>
|
||||||
/>
|
) : null}
|
||||||
|
|
||||||
{!agent.mobileOpen ? (
|
{!agent.mobileOpen ? (
|
||||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||||
@@ -1111,36 +1119,16 @@ function getScheduledConditionIdFromAlertId(alertId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileAgentToggle({
|
function MobileAgentToggle({
|
||||||
open,
|
|
||||||
personaState,
|
personaState,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
streaming,
|
streaming,
|
||||||
onOpen,
|
onOpen
|
||||||
onClose
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
|
||||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
if (open) {
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-20 right-3 z-40 lg:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭 Agent 面板"
|
|
||||||
title="关闭 Agent 面板"
|
|
||||||
onClick={onClose}
|
|
||||||
className="agent-panel-icon-button grid h-10 w-10 place-items-center rounded-2xl text-slate-700 transition"
|
|
||||||
>
|
|
||||||
<X size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||||
<button
|
<button
|
||||||
@@ -1155,8 +1143,8 @@ function MobileAgentToggle({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AgentPersona
|
<AgentPersona
|
||||||
className="h-10 w-10"
|
className="pointer-events-none h-10 w-10"
|
||||||
fallbackClassName="h-10 w-10"
|
fallbackClassName="pointer-events-none h-10 w-10"
|
||||||
state={personaState}
|
state={personaState}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("fitNetworkBounds", () => {
|
|||||||
expect(options).toMatchObject({
|
expect(options).toMatchObject({
|
||||||
duration: 600,
|
duration: 600,
|
||||||
maxZoom: 13,
|
maxZoom: 13,
|
||||||
padding: { top: 48, right: 48, bottom: 48, left: 48 }
|
padding: { top: 96, right: 48, bottom: 48, left: 48 }
|
||||||
});
|
});
|
||||||
expect(options).not.toHaveProperty("zoom");
|
expect(options).not.toHaveProperty("zoom");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type NetworkViewParams = {
|
|||||||
|
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 };
|
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
||||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||||
return getWorkbenchCameraPadding(1280, {
|
return getWorkbenchCameraPadding(1280, {
|
||||||
agentOpen: leftPanelOpen,
|
agentOpen: leftPanelOpen,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
|
|
||||||
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
|
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||||
@@ -31,3 +31,32 @@ test("Agent resize handle stays hidden in the mobile layout", async ({ page }) =
|
|||||||
|
|
||||||
await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden();
|
await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("mobile touch layout", () => {
|
||||||
|
test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } });
|
||||||
|
|
||||||
|
test("mobile Agent toggle opens the conversation panel by touch", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const openButton = page.getByRole("button", { name: "打开 Agent 面板" });
|
||||||
|
const buttonBox = await openButton.boundingBox();
|
||||||
|
expect(buttonBox).not.toBeNull();
|
||||||
|
|
||||||
|
await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2);
|
||||||
|
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||||
|
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
||||||
|
await page.getByRole("button", { name: "工况汇总" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
|
||||||
|
test("map notice is centered in the mobile viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "打开用户菜单" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
|
||||||
|
|
||||||
|
const notice = page.locator('[role="status"]').filter({ hasText: "数据状态" }).first();
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
|
||||||
|
const box = await notice.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
|
||||||
|
const leftInset = Math.round(box!.x);
|
||||||
|
const rightInset = Math.round(375 - box!.x - box!.width);
|
||||||
|
expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
|
||||||
|
test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
await expect(page.locator('meta[name="viewport"]')).toHaveAttribute(
|
||||||
|
"content",
|
||||||
|
/width=device-width/
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: /查看异常处置面板/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1024, height: 844 });
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const header = page.locator("header").first();
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
await expect.poll(async () => Math.round((await header.boundingBox())?.height ?? 0)).toBe(56);
|
||||||
|
|
||||||
|
await expect.poll(async () => header.evaluate((element) => {
|
||||||
|
return Array.from(element.querySelectorAll("span"))
|
||||||
|
.filter((span) => {
|
||||||
|
const style = getComputedStyle(span);
|
||||||
|
const rect = span.getBoundingClientRect();
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||||
|
})
|
||||||
|
.map((span) => span.textContent?.trim())
|
||||||
|
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "异常处置").length;
|
||||||
|
})).toBe(0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user