1 Commits
Author SHA1 Message Date
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
4 changed files with 136 additions and 20 deletions
@@ -5,7 +5,8 @@ import {
getAgentPanelMaxWidth, getAgentPanelMaxWidth,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchCameraPadding, getWorkbenchCameraPadding,
getWorkbenchViewportLayout getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout"; } from "./workbench-layout";
describe("workbench basemap surface tone", () => { describe("workbench basemap surface tone", () => {
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
}); });
describe("workbench floating panels", () => { describe("workbench floating panels", () => {
it("derives mobile dock and map control offsets from one overlay stack", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
});
});
it("uses bounded desktop and wide panel widths", () => { it("uses bounded desktop and wide panel widths", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({ expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500, agentWidth: 500,
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
mobileSheet: { mobileSheet: {
halfViewportRatio: 0.52, halfViewportRatio: 0.52,
fullTopGap: 68 fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
} }
} as const; } as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth( export function clampAgentPanelWidth(
width: number, width: number,
viewportWidth: number, viewportWidth: number,
+42 -19
View File
@@ -78,7 +78,8 @@ import {
import { import {
WORKBENCH_LAYOUT, WORKBENCH_LAYOUT,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout"; } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
@@ -220,9 +221,10 @@ export function MapWorkbenchPage({
timelineMode, timelineMode,
timelineVisible timelineVisible
); );
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
const timelineBottom = isLargeScreen const timelineBottom = isLargeScreen
? timelineScaleLayout.timelineBottom ? timelineScaleLayout.timelineBottom
: 12; : mobileOverlayLayout.timelineBottom;
useEffect(() => { useEffect(() => {
setTimelineMode((current) => { setTimelineMode((current) => {
@@ -987,19 +989,30 @@ export function MapWorkbenchPage({
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
/> />
</div> </div>
<button <div
type="button" role="region"
onClick={openTimelineForViewport} aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center gap-2 rounded-2xl border px-3 text-left text-xs text-slate-700 lg:hidden" className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
> >
<Clock3 size={15} className="text-blue-600" aria-hidden="true" /> <button
<span className="font-semibold"></span> type="button"
<span className="text-slate-500"> {operationalSummary.planned}</span> aria-label="打开完整运行时间轴"
<span className="text-slate-500"> {operationalSummary.active}</span> onClick={openTimelineForViewport}
<span className="text-amber-700"> {operationalSummary.attention}</span> className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
<span className="text-rose-700"> {operationalSummary.exceptions}</span> >
<span className="ml-auto text-slate-500"></span> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
</button> <span className="shrink-0 font-semibold"></span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="text-rose-700"> {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
</span>
</button>
</div>
</> : null </> : null
)} )}
mapContent={( mapContent={(
@@ -1019,10 +1032,13 @@ export function MapWorkbenchPage({
<ToolbarPanel {...toolbarPanelProps} /> <ToolbarPanel {...toolbarPanelProps} />
</div> </div>
<div <div
className="absolute bottom-0 right-0 flex flex-col items-end gap-2" className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{ bottom: timelineScaleLayout.scaleBottom }} style={{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties}
> >
<div className="flex items-end gap-2 pr-2"> <div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} /> <MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div> </div>
<MapScaleLine <MapScaleLine
@@ -1077,7 +1093,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? ( {mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden"> <div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
>
<ToolbarPanel <ToolbarPanel
{...toolbarPanelProps} {...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -1108,7 +1127,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}> <div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? ( {mobileSheet === null ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
@@ -1167,6 +1189,7 @@ export function MapWorkbenchPage({
events={operationalEvents} events={operationalEvents}
mode="expanded" mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId} selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent} onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection} onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
+59
View File
@@ -113,6 +113,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x); expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
}); });
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
await expect(timelineSummary).toBeVisible();
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
await expect(timelineSheet).toBeVisible();
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
await expect(timelineSheet).toBeHidden();
await expect(timelineSummary).toBeVisible();
});
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
const toolPanel = page.getByRole("region", { name: "更多" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(toolPanel).toBeVisible();
await expect
.poll(async () => {
const [panelBox, dockBox] = await Promise.all([
toolPanel.boundingBox(),
mapDock.boundingBox()
]);
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(panelBox.y + panelBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
});
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const mapZoom = page.getByRole("group", { name: "地图缩放" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(mapZoom).toBeVisible();
await expect
.poll(async () => {
const [zoomBox, dockBox] = await Promise.all([
mapZoom.boundingBox(),
mapDock.boundingBox()
]);
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
const zoomBox = await mapZoom.boundingBox();
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
});
test("Agent content buttons do not gain a border while pressed", async ({ page }) => { test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });