Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28287a1447 | ||
|
|
abef9ffabf |
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ describe("Keycloak authentication", () => {
|
|||||||
expect(client.init).toHaveBeenCalledWith({
|
expect(client.init).toHaveBeenCalledWith({
|
||||||
onLoad: "login-required",
|
onLoad: "login-required",
|
||||||
flow: "standard",
|
flow: "standard",
|
||||||
pkceMethod: "S256"
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
});
|
});
|
||||||
expect(authentication.user).toEqual({
|
expect(authentication.user).toEqual({
|
||||||
name: "张调度",
|
name: "张调度",
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export async function initializeAuthentication(
|
|||||||
const authenticated = await keycloak.init({
|
const authenticated = await keycloak.init({
|
||||||
onLoad: "login-required",
|
onLoad: "login-required",
|
||||||
flow: "standard",
|
flow: "standard",
|
||||||
pkceMethod: "S256"
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user