feat: refresh workbench visual layout
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@deck.gl/core": "^9.3.7",
|
||||
"@deck.gl/layers": "^9.3.7",
|
||||
"@deck.gl/mapbox": "^9.3.7",
|
||||
"@fontsource-variable/noto-sans-sc": "5.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.14",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
|
||||
@@ -10,6 +10,9 @@ export default defineConfig({
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
deviceScaleFactor: 1,
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
|
||||
Generated
+8
@@ -20,6 +20,9 @@ importers:
|
||||
'@deck.gl/mapbox':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
|
||||
'@fontsource-variable/noto-sans-sc':
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -681,6 +684,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0':
|
||||
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -4671,6 +4677,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
@@ -21,8 +20,7 @@ export function AgentCollapsedRail({
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
DatabaseZap,
|
||||
History,
|
||||
@@ -77,7 +78,13 @@ import type {
|
||||
AgentUiResult
|
||||
} from "../types";
|
||||
|
||||
export type AgentCommandPanelPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
presentation?: AgentCommandPanelPresentation;
|
||||
collapsing?: boolean;
|
||||
personaState?: PersonaState;
|
||||
sessionTitle?: string;
|
||||
@@ -127,6 +134,7 @@ const AGENT_CAPABILITIES = [
|
||||
] as const;
|
||||
|
||||
export function AgentCommandPanel({
|
||||
presentation = "desktop-dock",
|
||||
collapsing = false,
|
||||
personaState,
|
||||
sessionTitle = "新对话",
|
||||
@@ -221,8 +229,11 @@ export function AgentCommandPanel({
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
"agent-panel-shell",
|
||||
"acrylic-panel border",
|
||||
"rounded-2xl",
|
||||
presentation === "desktop-dock"
|
||||
? "acrylic-panel rounded-r-2xl border-y border-r"
|
||||
: presentation === "desktop-floating"
|
||||
? "acrylic-panel rounded-2xl border"
|
||||
: "rounded-none border-0 bg-transparent",
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
@@ -283,12 +294,16 @@ export function AgentCommandPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
aria-label={presentation === "mobile-sheet" ? "关闭 Agent 面板" : "折叠 Agent 面板"}
|
||||
title={presentation === "mobile-sheet" ? "关闭 Agent 面板" : "折叠 Agent 面板"}
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
{presentation === "mobile-sheet" ? (
|
||||
<ChevronDown size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -373,7 +388,7 @@ export function AgentCommandPanel({
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState />
|
||||
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -875,35 +890,51 @@ function AgentReadyMark() {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState() {
|
||||
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
|
||||
className="mx-auto w-full max-w-[360px]"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className="px-4 py-5">
|
||||
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
|
||||
<div
|
||||
className={cn(
|
||||
"surface-well grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.88_0.02_260)]",
|
||||
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||||
)}
|
||||
>
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className="text-balance text-lg font-semibold leading-7 text-slate-900"
|
||||
className={cn(
|
||||
"text-balance font-semibold text-slate-900",
|
||||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||
)}
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
|
||||
<p
|
||||
className={cn(
|
||||
"text-pretty text-left text-sm leading-6 text-slate-500",
|
||||
compact ? "mt-3" : "mt-5"
|
||||
)}
|
||||
>
|
||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
|
||||
className={cn(
|
||||
"surface-well grid grid-cols-2 overflow-hidden rounded-xl",
|
||||
compact && "hidden"
|
||||
)}
|
||||
aria-label="Agent 分析能力"
|
||||
>
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
||||
|
||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "acrylic-panel text-slate-900",
|
||||
success: "acrylic-panel text-slate-900",
|
||||
warning: "acrylic-panel text-slate-900",
|
||||
error: "acrylic-panel text-slate-900",
|
||||
loading: "acrylic-panel text-slate-900"
|
||||
info: "glass-transient text-slate-900",
|
||||
success: "glass-transient text-slate-900",
|
||||
warning: "glass-transient text-slate-900",
|
||||
error: "glass-transient text-slate-900",
|
||||
loading: "glass-transient text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
@@ -55,7 +55,11 @@ export function MapNoticeContent({
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||
action={
|
||||
actionLabel && onAction ? (
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
@@ -80,13 +84,22 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
||||
toneClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||
<span
|
||||
className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||
function MapNoticeIcon({
|
||||
tone,
|
||||
icon: Icon
|
||||
}: {
|
||||
tone: MapNoticeTone;
|
||||
icon: ReturnType<typeof getNoticeIcon>;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -96,7 +109,11 @@ function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: Return
|
||||
noticeIconSurfaceClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||
<Icon
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={tone === "loading" ? "animate-spin" : undefined}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -112,8 +129,12 @@ function MapNoticeBody({
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||
{title ? (
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
) : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
|
||||
{message}
|
||||
</span>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
@@ -124,7 +145,10 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
className={cn(
|
||||
"mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
export type MobileWorkbenchSheetSnap = "peek" | "half" | "full";
|
||||
|
||||
export function getMobileWorkbenchSheetHeight(
|
||||
snap: MobileWorkbenchSheetSnap,
|
||||
viewportHeight: number
|
||||
) {
|
||||
if (snap === "peek") {
|
||||
return WORKBENCH_LAYOUT.mobileSheet.peekHeight;
|
||||
}
|
||||
|
||||
if (snap === "half") {
|
||||
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
WORKBENCH_LAYOUT.mobileSheet.peekHeight,
|
||||
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
describe("mobile workbench sheet heights", () => {
|
||||
it("keeps the three deterministic snap heights", () => {
|
||||
expect(getMobileWorkbenchSheetHeight("peek", 844)).toBe(96);
|
||||
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
|
||||
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
|
||||
});
|
||||
|
||||
it("always leaves the configured map strip visible at full height", () => {
|
||||
const viewportHeight = 667;
|
||||
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
|
||||
|
||||
expect(viewportHeight - sheetHeight).toBe(68);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
getMobileWorkbenchSheetHeight,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./mobile-workbench-sheet-layout";
|
||||
|
||||
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
type MobileWorkbenchSheetProps = {
|
||||
label: string;
|
||||
snap: MobileWorkbenchSheetSnap;
|
||||
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
closing?: boolean;
|
||||
onCloseComplete?: () => void;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["peek", "half", "full"];
|
||||
|
||||
export function MobileWorkbenchSheet({
|
||||
label,
|
||||
snap,
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
closing = false,
|
||||
onCloseComplete
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(
|
||||
typeof window === "undefined" ? 844 : window.innerHeight
|
||||
);
|
||||
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
y: event.clientY,
|
||||
height: restingHeight
|
||||
};
|
||||
draggedRef.current = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = start.y - event.clientY;
|
||||
if (Math.abs(delta) > 4) {
|
||||
draggedRef.current = true;
|
||||
}
|
||||
|
||||
const minHeight = getMobileWorkbenchSheetHeight("peek", window.innerHeight);
|
||||
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
||||
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
||||
if (!pointerStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
||||
pointerStartRef.current = null;
|
||||
setDragHeight(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (!draggedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (draggedRef.current) {
|
||||
draggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = SNAP_ORDER.indexOf(snap);
|
||||
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
onSnapChange("peek");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
onSnapChange("full");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
data-snap={snap}
|
||||
className={cn(
|
||||
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
||||
closing && "pointer-events-none",
|
||||
className
|
||||
)}
|
||||
style={{ height: closing ? 0 : (dragHeight ?? restingHeight) }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (closing && event.propertyName === "height" && event.target === event.currentTarget) {
|
||||
onCloseComplete?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${label}`}
|
||||
className="absolute right-2 grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdjacentSnap(
|
||||
current: MobileWorkbenchSheetSnap,
|
||||
direction: -1 | 1
|
||||
): MobileWorkbenchSheetSnap {
|
||||
const currentIndex = SNAP_ORDER.indexOf(current);
|
||||
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
||||
return SNAP_ORDER[nextIndex] ?? current;
|
||||
}
|
||||
|
||||
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
||||
return SNAP_ORDER.reduce((closest, candidate) => {
|
||||
const candidateDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
||||
);
|
||||
const closestDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
||||
);
|
||||
return candidateDistance < closestDistance ? candidate : closest;
|
||||
}, "peek");
|
||||
}
|
||||
|
||||
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
||||
if (snap === "peek") {
|
||||
return "预览档";
|
||||
}
|
||||
|
||||
if (snap === "half") {
|
||||
return "半屏档";
|
||||
}
|
||||
|
||||
return "全屏档";
|
||||
}
|
||||
@@ -36,10 +36,18 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
|
||||
"surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
"surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
presentation?: ScheduledConditionFeedPresentation;
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
@@ -60,6 +68,7 @@ type ScheduledConditionFeedFocusRequest = {
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
presentation = "desktop-dock",
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
@@ -74,9 +83,14 @@ export function ScheduledConditionFeed({
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
@@ -89,10 +103,16 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
() =>
|
||||
conditions.filter(
|
||||
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
|
||||
),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const statusFilterOptions = useMemo(
|
||||
() => createStatusFilterOptions(taskFilteredConditions),
|
||||
[taskFilteredConditions]
|
||||
);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
@@ -101,7 +121,10 @@ export function ScheduledConditionFeed({
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(filteredConditions),
|
||||
[filteredConditions]
|
||||
);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
@@ -170,7 +193,9 @@ export function ScheduledConditionFeed({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
const targetCondition = allTimelineConditions.find(
|
||||
(condition) => condition.id === focusConditionId
|
||||
);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
@@ -180,11 +205,20 @@ export function ScheduledConditionFeed({
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
}, [
|
||||
allTimelineConditions,
|
||||
focusConditionId,
|
||||
focusRequestId,
|
||||
onFocusRequestHandled,
|
||||
updateExpanded,
|
||||
updateSelectedConditionId
|
||||
]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
@@ -226,7 +260,8 @@ export function ScheduledConditionFeed({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
message:
|
||||
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -240,15 +275,34 @@ export function ScheduledConditionFeed({
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? "max-h-[calc(100dvh-8rem)] rounded-2xl"
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -276,13 +330,23 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
mobileSheet
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
mobileSheet
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -295,9 +359,15 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
presentation={presentation}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
@@ -305,7 +375,7 @@ export function ScheduledConditionFeed({
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
@@ -325,6 +395,7 @@ export function ScheduledConditionFeed({
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
presentation: ScheduledConditionFeedPresentation;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
@@ -344,6 +415,7 @@ type ConditionTimelinePanelProps = {
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
presentation,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
@@ -365,9 +437,14 @@ function ConditionTimelinePanel({
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
data-testid="scheduled-condition-timeline"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-[calc(100dvh-8.75rem)] max-h-[calc(100dvh-8.75rem)]"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -387,7 +464,12 @@ function ConditionTimelinePanel({
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<ConditionGroupHeader
|
||||
icon={ClipboardList}
|
||||
title="预约任务"
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
@@ -397,7 +479,11 @@ function ConditionTimelinePanel({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<ConditionGroupHeader
|
||||
icon={History}
|
||||
title="最近工况"
|
||||
count={loading ? 0 : recentConditions.length}
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
@@ -410,7 +496,11 @@ function ConditionTimelinePanel({
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
description={
|
||||
filterActive
|
||||
? "调整任务或状态筛选后查看其他工况记录。"
|
||||
: "新的工况任务完成后会出现在这里。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,7 +536,7 @@ function ConditionFilterBar({
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
@@ -519,10 +609,15 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
<span
|
||||
className="block truncate font-semibold text-slate-800"
|
||||
title={selectedOption?.label}
|
||||
>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">
|
||||
{selectedOption?.count ?? 0} 条
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
@@ -536,7 +631,10 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
sideOffset={6}
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
@@ -582,7 +680,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
@@ -626,7 +727,10 @@ function ConditionDetailSkeleton() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<div
|
||||
key={index}
|
||||
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
|
||||
>
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
@@ -653,9 +757,19 @@ type ConditionGroupHeaderProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
function ConditionGroupHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
compact = false
|
||||
}: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
|
||||
compact ? "mb-1" : "mb-2"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
@@ -694,7 +808,12 @@ type ConditionTimelineRowProps = {
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
function ConditionTimelineRow({
|
||||
condition,
|
||||
selected,
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
|
||||
@@ -705,13 +824,15 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
selected
|
||||
? "surface-reading border-blue-100 text-blue-900"
|
||||
: "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
|
||||
{formatTime(condition.scheduledAt)}
|
||||
</span>
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
@@ -728,21 +849,27 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
: getStatusIconClassName(condition.status)
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
<StatusIcon
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={condition.status === "running" ? "animate-spin" : undefined}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
selected ? "text-blue-900" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
<span className="block truncate text-xs font-semibold leading-4">
|
||||
{condition.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||
{condition.summary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { ComponentProps } from "react";
|
||||
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import {
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
@@ -14,105 +13,73 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
mobileOpen: boolean;
|
||||
mobilePanelCollapsing: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
onCollapsePanel: () => void;
|
||||
onCloseMobilePanel: () => void;
|
||||
onExpandPanel: () => void;
|
||||
onOpenMobilePanel: () => void;
|
||||
onWidthCommit?: (width: number) => void;
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
onCollapsePanel,
|
||||
onCloseMobilePanel,
|
||||
onExpandPanel,
|
||||
onOpenMobilePanel
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth);
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||
|
||||
useEffect(() => {
|
||||
onWidthCommit?.(committedWidth);
|
||||
}, [committedWidth, onWidthCommit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={resize.handleKeyDown}
|
||||
onPointerDown={resize.handlePointerDown}
|
||||
>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[620px] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-floating"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileOpen ? (
|
||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={mobilePanelCollapsing}
|
||||
onCollapse={onCloseMobilePanel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={onOpenMobilePanel}
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
"grid h-12 w-12 place-items-center text-violet-700",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={resize.handleKeyDown}
|
||||
onPointerDown={resize.handlePointerDown}
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
FlaskConical,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
@@ -77,7 +75,8 @@ export function WorkbenchTopBar({
|
||||
onExportConfig
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const activeScenario =
|
||||
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
@@ -85,11 +84,18 @@ export function WorkbenchTopBar({
|
||||
return (
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||
供水管网智能调度系统
|
||||
</h1>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,18 +139,31 @@ export function WorkbenchTopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{devPanelEnabled ? <div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
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={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
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={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
@@ -199,12 +218,7 @@ function TaskTickerToggle({
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -212,17 +226,13 @@ function TaskTickerToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
@@ -230,12 +240,7 @@ function ConditionFeedToggle({
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
@@ -243,12 +248,22 @@ function ConditionFeedToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
function HeaderReadout({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
@@ -16,19 +17,31 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const resizeLeftRef = useRef(0);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const widthRef = useRef<number | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth((currentWidth) =>
|
||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
|
||||
);
|
||||
if (widthRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
setCommittedWidth(nextWidth);
|
||||
}, [viewportWidth]);
|
||||
|
||||
const resize = useCallback((clientX: number) => {
|
||||
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
|
||||
const nextWidth = clampAgentPanelWidth(
|
||||
clientX - resizeLeftRef.current,
|
||||
window.innerWidth
|
||||
);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
@@ -48,6 +61,7 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
const stopResizing = () => {
|
||||
cleanupRef.current?.();
|
||||
setResizing(false);
|
||||
setCommittedWidth(widthRef.current);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
@@ -76,16 +90,20 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth);
|
||||
widthRef.current = clampedWidth;
|
||||
setWidth(clampedWidth);
|
||||
setCommittedWidth(clampedWidth);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
committedWidth,
|
||||
handleKeyDown,
|
||||
handlePointerDown,
|
||||
panelRef,
|
||||
|
||||
@@ -7,15 +7,31 @@ export function useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth
|
||||
}: {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
conditionPanelExpanded?: boolean;
|
||||
agentPanelWidth?: number;
|
||||
}) {
|
||||
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
|
||||
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
|
||||
const valuesRef = useRef({
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
valuesRef.current = {
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
};
|
||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new WorkbenchMapController({
|
||||
@@ -24,14 +40,24 @@ export function useWorkbenchMapController({
|
||||
getPadding: () => {
|
||||
const map = mapRef.current;
|
||||
return map
|
||||
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
|
||||
? getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
valuesRef.current.leftPanelOpen,
|
||||
valuesRef.current.rightPanelOpen,
|
||||
valuesRef.current.conditionPanelExpanded,
|
||||
valuesRef.current.agentPanelWidth
|
||||
)
|
||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const controller = controllerRef.current;
|
||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
||||
const state = useSyncExternalStore(
|
||||
controller.subscribe,
|
||||
controller.getSnapshot,
|
||||
controller.getSnapshot
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
return { controller, state };
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ declare global {
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
@@ -88,23 +86,16 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
|
||||
}, [leftPanelOpen, rightPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
}, [impactVisible]);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchCameraPadding,
|
||||
getWorkbenchViewportLayout
|
||||
} from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
@@ -11,3 +17,60 @@ describe("workbench basemap surface tone", () => {
|
||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workbench floating panels", () => {
|
||||
it("uses bounded desktop and wide panel widths", () => {
|
||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||
agentWidth: 460,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48
|
||||
});
|
||||
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
|
||||
agentWidth: 500,
|
||||
conditionExpandedWidth: 960
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks the expanded condition panel in camera padding", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
conditionExpanded: false
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 484 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
conditionExpanded: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 484 });
|
||||
});
|
||||
|
||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 620,
|
||||
conditionOpen: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 900,
|
||||
conditionOpen: false
|
||||
}).left
|
||||
).toBe(644);
|
||||
});
|
||||
|
||||
it("reserves room for the compact condition panel on narrow desktops", () => {
|
||||
expect(getAgentPanelMaxWidth(1024)).toBe(484);
|
||||
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
|
||||
expect(getAgentPanelMaxWidth(1440)).toBe(620);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,32 +4,58 @@ export const WORKBENCH_LAYOUT = {
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
maxAgentViewportRatio: 0.5,
|
||||
maxAgentWidth: 620,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 960,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
},
|
||||
mobileSheet: {
|
||||
peekHeight: 96,
|
||||
halfViewportRatio: 0.52,
|
||||
fullTopGap: 68
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
|
||||
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
|
||||
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
|
||||
return Math.round(
|
||||
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth))
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentPanelMaxWidth(viewportWidth: number) {
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const widthWithCompactCondition =
|
||||
viewportWidth -
|
||||
12 -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
layout.conditionWidth -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
layout.toolbarWidth;
|
||||
|
||||
return Math.max(
|
||||
layout.agentWidth,
|
||||
Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithCompactCondition)
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkbenchPanelState = {
|
||||
agentOpen: boolean;
|
||||
agentWidth?: number;
|
||||
conditionOpen: boolean;
|
||||
conditionExpanded?: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
@@ -37,6 +63,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
export type WorkbenchViewportLayout = {
|
||||
agentWidth: number;
|
||||
conditionWidth: number;
|
||||
conditionExpandedWidth: number;
|
||||
toolbarWidth: number;
|
||||
tickerWidth: number;
|
||||
};
|
||||
@@ -65,8 +92,16 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
|
||||
}
|
||||
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
||||
const agentWidth = panels.agentOpen
|
||||
? panels.agentWidth === undefined
|
||||
? layout.agentWidth
|
||||
: clampAgentPanelWidth(panels.agentWidth, viewportWidth)
|
||||
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen
|
||||
? panels.conditionExpanded
|
||||
? layout.conditionExpandedWidth
|
||||
: layout.conditionWidth
|
||||
: 0;
|
||||
|
||||
return {
|
||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||
@@ -82,6 +117,8 @@ export function getWorkbenchLayoutCssVariables() {
|
||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
|
||||
"--workbench-condition-expanded-width-wide": `${WORKBENCH_LAYOUT.wide.conditionExpandedWidth}px`,
|
||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
type CSSProperties
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
@@ -29,6 +31,10 @@ import {
|
||||
import { env } from "@/shared/config/env";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import {
|
||||
MobileWorkbenchSheet,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
@@ -92,10 +98,18 @@ export function MapWorkbenchPage() {
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||
);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
const [mobileSheetClosing, setMobileSheetClosing] = useState(false);
|
||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||||
@@ -133,6 +147,10 @@ export function MapWorkbenchPage() {
|
||||
const handleChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
if (mediaQuery.matches) {
|
||||
setMobileSheet(null);
|
||||
setMobileSheetClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange();
|
||||
@@ -167,20 +185,88 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < 1440) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
agent.expandPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
agent.openMobilePanel();
|
||||
setMobileSheetClosing(false);
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
|
||||
const closeMobileSheet = useCallback(() => {
|
||||
if (prefersReducedMotion) {
|
||||
setMobileSheet(null);
|
||||
setMobileSheetClosing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileSheetClosing(true);
|
||||
}, [prefersReducedMotion]);
|
||||
|
||||
const completeMobileSheetClose = useCallback(() => {
|
||||
setMobileSheet(null);
|
||||
setMobileSheetClosing(false);
|
||||
}, []);
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
||||
setAgentCollapsedForCondition(agent.panelOpen);
|
||||
agent.collapsePanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
setConditionFeedExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
setActiveToolId(null);
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileSheet === "condition") {
|
||||
closeMobileSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
setMobileSheetClosing(false);
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheet("condition");
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
const rightPanelExpanded =
|
||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature: handleSelectFeature,
|
||||
selectedFeature: detailFeature
|
||||
@@ -189,7 +275,9 @@ export function MapWorkbenchPage() {
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
|
||||
const activeAgentUiResults = useMemo(
|
||||
@@ -223,10 +311,9 @@ export function MapWorkbenchPage() {
|
||||
mode: activeMeasureModeId,
|
||||
unit: activeMeasureUnitId
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -489,6 +576,11 @@ export function MapWorkbenchPage() {
|
||||
|
||||
setConditionFeedVisible(true);
|
||||
setActiveToolId(null);
|
||||
if (!isLargeScreen) {
|
||||
setMobileSheetClosing(false);
|
||||
setMobileSheet("condition");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
@@ -608,7 +700,13 @@ export function MapWorkbenchPage() {
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
),
|
||||
duration: trustedAction.durationMs ?? 500
|
||||
});
|
||||
showMapNotice({
|
||||
@@ -757,7 +855,14 @@ export function MapWorkbenchPage() {
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
data-basemap-tone={basemapTone}
|
||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
||||
style={
|
||||
{
|
||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||
"--workbench-agent-current-width": `${
|
||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
@@ -773,17 +878,14 @@ export function MapWorkbenchPage() {
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
conditionFeedVisible={shouldShowConditionFeed}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={() => {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
setActiveToolId(null);
|
||||
}}
|
||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
@@ -800,19 +902,16 @@ export function MapWorkbenchPage() {
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
mobileOpen={agent.mobileOpen}
|
||||
mobilePanelCollapsing={agent.mobilePanelCollapsing}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onCloseMobilePanel={agent.closeMobilePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onOpenMobilePanel={agent.openMobilePanel}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
/>
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -820,13 +919,14 @@ export function MapWorkbenchPage() {
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
@@ -851,8 +951,8 @@ export function MapWorkbenchPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null ? (
|
||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel
|
||||
{...toolbarPanelProps}
|
||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||
@@ -890,16 +990,67 @@ export function MapWorkbenchPage() {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
||||
{!agent.mobileOpen ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={openAgentPanelForViewport}
|
||||
className="acrylic-control grid h-12 w-12 shrink-0 place-items-center rounded-xl border text-violet-700 active:scale-95"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-9 w-9" state={agent.personaState} />
|
||||
</button>
|
||||
<MapToolbar items={toolbarItems} orientation="horizontal" className="justify-center" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
className="acrylic-control grid h-12 w-12 shrink-0 place-items-center rounded-xl border text-blue-700 active:scale-95"
|
||||
>
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mobileSheet ? (
|
||||
<MobileWorkbenchSheet
|
||||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
||||
snap={mobileSheetSnap}
|
||||
onSnapChange={setMobileSheetSnap}
|
||||
onClose={closeMobileSheet}
|
||||
closing={mobileSheetClosing}
|
||||
onCloseComplete={completeMobileSheetClose}
|
||||
>
|
||||
{mobileSheet === "agent" ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
presentation="mobile-sheet"
|
||||
onCollapse={closeMobileSheet}
|
||||
/>
|
||||
) : (
|
||||
<ScheduledConditionFeed
|
||||
presentation="mobile-sheet"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
)}
|
||||
</MobileWorkbenchSheet>
|
||||
) : null}
|
||||
|
||||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||||
|
||||
{!mapReady && !mapError ? (
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
@@ -19,12 +15,40 @@ function createMap(width = 1280, height = 720) {
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 96 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 484 }
|
||||
},
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 96 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 484 }
|
||||
}
|
||||
])(
|
||||
"covers agent=$agentOpen and condition=$conditionOpen",
|
||||
({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses a committed Agent width for camera-safe operations", () => {
|
||||
expect(getWorkbenchPadding(true, false, false, 620)).toEqual({
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 32,
|
||||
left: 644
|
||||
});
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
@@ -59,7 +83,7 @@ describe("fitNetworkBounds", () => {
|
||||
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
|
||||
|
||||
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.8105, 6);
|
||||
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
|
||||
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
|
||||
expect(options).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
@@ -14,24 +12,35 @@ export type NetworkViewParams = {
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||
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,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
map: MapLibreMap,
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
rightPanelOpen: boolean,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -41,14 +50,11 @@ export function fitNetworkBounds(
|
||||
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
|
||||
padding: PaddingOptions = GLOBAL_VIEW_PADDING
|
||||
) {
|
||||
map.fitBounds(
|
||||
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
|
||||
{
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
}
|
||||
);
|
||||
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
@@ -84,10 +90,7 @@ function getMapViewportSize(map: MapLibreMap) {
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
return [
|
||||
webMercatorToLngLat(minX, minY),
|
||||
webMercatorToLngLat(maxX, maxY)
|
||||
];
|
||||
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
|
||||
}
|
||||
|
||||
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||
|
||||
@@ -185,8 +185,8 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 0.86,
|
||||
"raster-saturation": -0.16,
|
||||
"raster-contrast": 0.02
|
||||
"raster-saturation": -0.3,
|
||||
"raster-contrast": 0.1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+111
-68
@@ -1,3 +1,4 @@
|
||||
@import "@fontsource-variable/noto-sans-sc";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@source "../node_modules/streamdown/dist";
|
||||
@@ -71,24 +72,27 @@
|
||||
background: oklch(94.5% 0.012 250);
|
||||
--canvas-map: oklch(94.5% 0.012 250);
|
||||
--canvas-map-grid: rgba(37, 99, 235, 0.06);
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.74);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.58);
|
||||
--acrylic-control: rgba(249, 251, 255, 0.84);
|
||||
--acrylic-panel-blur: 22px;
|
||||
--acrylic-navigation: rgba(234, 243, 250, 0.64);
|
||||
--acrylic-panel: rgba(223, 235, 246, 0.56);
|
||||
--acrylic-control: rgba(242, 248, 253, 0.74);
|
||||
--acrylic-panel-blur: 24px;
|
||||
--acrylic-control-blur: 18px;
|
||||
--acrylic-backdrop-saturation: 1.2;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.05);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.22);
|
||||
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.06'/%3E%3C/svg%3E");
|
||||
--acrylic-border: rgba(255, 255, 255, 0.58);
|
||||
--acrylic-outline: rgba(71, 85, 105, 0.18);
|
||||
--acrylic-highlight: rgba(255, 255, 255, 0.82);
|
||||
--acrylic-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
--surface-dock: oklch(97% 0.006 250);
|
||||
--surface-control: rgba(252, 253, 255, 0.96);
|
||||
--surface-well: rgba(232, 240, 248, 0.2);
|
||||
--surface-reading: rgba(255, 255, 255, 0.985);
|
||||
--glass-transient: rgba(248, 252, 255, 0.88);
|
||||
--acrylic-backdrop-saturation: 1.08;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.035);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
|
||||
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.035'/%3E%3C/svg%3E");
|
||||
--acrylic-border: rgba(255, 255, 255, 0.52);
|
||||
--acrylic-outline: rgba(63, 83, 108, 0.18);
|
||||
--acrylic-highlight: rgba(255, 255, 255, 0.68);
|
||||
--shadow-navigation: 0 1px 0 rgba(48, 65, 85, 0.14);
|
||||
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
|
||||
--shadow-control: 0 6px 18px rgba(15, 33, 55, 0.1);
|
||||
--shadow-transient: 0 14px 34px rgba(15, 33, 55, 0.18);
|
||||
--surface-dock: oklch(96.5% 0.012 248);
|
||||
--surface-control: rgba(239, 246, 252, 0.92);
|
||||
--surface-well: rgba(213, 227, 239, 0.66);
|
||||
--surface-reading: rgba(250, 252, 254, 0.96);
|
||||
--glass-transient: rgba(232, 242, 250, 0.66);
|
||||
--surface-border: oklch(86.5% 0.012 250);
|
||||
--surface-divider: oklch(90.5% 0.009 250);
|
||||
--surface-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
@@ -135,35 +139,36 @@
|
||||
}
|
||||
|
||||
[data-basemap-tone="light"] {
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.68);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.7);
|
||||
--acrylic-control: rgba(249, 251, 255, 0.78);
|
||||
--acrylic-navigation: rgba(234, 243, 250, 0.64);
|
||||
--acrylic-panel: rgba(223, 235, 246, 0.56);
|
||||
--acrylic-control: rgba(242, 248, 253, 0.74);
|
||||
--acrylic-panel-blur: 24px;
|
||||
--acrylic-control-blur: 20px;
|
||||
--acrylic-backdrop-saturation: 1.22;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.04);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.18);
|
||||
--surface-control: rgba(252, 253, 255, 0.95);
|
||||
--surface-well: rgba(232, 240, 248, 0.34);
|
||||
--surface-reading: rgba(255, 255, 255, 0.97);
|
||||
--glass-transient: rgba(238, 247, 253, 0.56);
|
||||
--acrylic-control-blur: 18px;
|
||||
--acrylic-backdrop-saturation: 1.08;
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
|
||||
--surface-control: rgba(239, 246, 252, 0.92);
|
||||
--surface-well: rgba(213, 227, 239, 0.66);
|
||||
--surface-reading: rgba(250, 252, 254, 0.96);
|
||||
--glass-transient: rgba(232, 242, 250, 0.66);
|
||||
}
|
||||
|
||||
[data-basemap-tone="satellite"] {
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.82);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.7);
|
||||
--acrylic-control: rgba(250, 252, 255, 0.9);
|
||||
--acrylic-panel-blur: 20px;
|
||||
--acrylic-control-blur: 16px;
|
||||
--acrylic-backdrop-saturation: 1.1;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.06);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.24);
|
||||
--surface-control: rgba(252, 253, 255, 0.98);
|
||||
--surface-well: rgba(232, 240, 248, 0.28);
|
||||
--surface-reading: rgba(255, 255, 255, 0.99);
|
||||
--glass-transient: rgba(238, 247, 253, 0.72);
|
||||
--acrylic-outline: rgba(71, 85, 105, 0.24);
|
||||
--acrylic-shadow: 0 14px 38px rgba(15, 23, 42, 0.2);
|
||||
--acrylic-navigation: rgba(232, 241, 249, 0.8);
|
||||
--acrylic-panel: rgba(220, 232, 243, 0.72);
|
||||
--acrylic-control: rgba(241, 247, 252, 0.86);
|
||||
--acrylic-panel-blur: 24px;
|
||||
--acrylic-control-blur: 18px;
|
||||
--acrylic-backdrop-saturation: 1.02;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.04);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
|
||||
--surface-control: rgba(242, 248, 253, 0.96);
|
||||
--surface-well: rgba(217, 230, 241, 0.78);
|
||||
--surface-reading: rgba(252, 253, 255, 0.985);
|
||||
--glass-transient: rgba(232, 242, 250, 0.76);
|
||||
--acrylic-outline: rgba(43, 63, 86, 0.28);
|
||||
--shadow-panel: 0 14px 36px rgba(7, 18, 32, 0.22);
|
||||
--shadow-control: 0 7px 20px rgba(7, 18, 32, 0.16);
|
||||
--shadow-transient: 0 16px 38px rgba(7, 18, 32, 0.26);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -177,9 +182,7 @@ body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
||||
"PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-family: "Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -195,8 +198,7 @@ input:focus-visible {
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.08)),
|
||||
linear-gradient(90deg, var(--canvas-map-grid) 1px, transparent 1px),
|
||||
linear-gradient(var(--canvas-map-grid) 1px, transparent 1px),
|
||||
var(--canvas-map);
|
||||
linear-gradient(var(--canvas-map-grid) 1px, transparent 1px), var(--canvas-map);
|
||||
background-size:
|
||||
auto,
|
||||
68px 68px,
|
||||
@@ -224,28 +226,27 @@ input:focus-visible {
|
||||
border-color: var(--acrylic-border);
|
||||
background-image:
|
||||
linear-gradient(var(--acrylic-exclusion), var(--acrylic-exclusion)),
|
||||
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)),
|
||||
var(--acrylic-noise);
|
||||
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)), var(--acrylic-noise);
|
||||
background-blend-mode: exclusion, luminosity, soft-light;
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--acrylic-highlight),
|
||||
0 0 0 1px var(--acrylic-outline),
|
||||
var(--acrylic-shadow);
|
||||
var(--shadow-panel);
|
||||
}
|
||||
|
||||
.acrylic-navigation {
|
||||
background-color: var(--acrylic-navigation);
|
||||
box-shadow:
|
||||
0 1px 0 var(--acrylic-outline),
|
||||
0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--shadow-navigation);
|
||||
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur))
|
||||
saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.acrylic-panel {
|
||||
background-color: var(--acrylic-panel);
|
||||
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur))
|
||||
saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.acrylic-control {
|
||||
@@ -253,9 +254,10 @@ input:focus-visible {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
||||
0 0 0 1px rgba(59, 130, 246, 0.14),
|
||||
0 14px 34px rgba(15, 23, 42, 0.13);
|
||||
var(--shadow-control);
|
||||
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur))
|
||||
saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -267,10 +269,18 @@ input:focus-visible {
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.surface-dock { background-color: var(--surface-dock); }
|
||||
.surface-control { background-color: var(--surface-control); }
|
||||
.surface-well { background-color: var(--surface-well); }
|
||||
.surface-reading { background-color: var(--surface-reading); }
|
||||
.surface-dock {
|
||||
background-color: var(--surface-dock);
|
||||
}
|
||||
.surface-control {
|
||||
background-color: var(--surface-control);
|
||||
}
|
||||
.surface-well {
|
||||
background-color: var(--surface-well);
|
||||
}
|
||||
.surface-reading {
|
||||
background-color: var(--surface-reading);
|
||||
}
|
||||
}
|
||||
|
||||
.glass-transient {
|
||||
@@ -278,9 +288,10 @@ input:focus-visible {
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--acrylic-highlight),
|
||||
0 0 0 1px var(--acrylic-outline),
|
||||
0 10px 28px rgba(15, 23, 42, 0.14);
|
||||
var(--shadow-transient);
|
||||
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur))
|
||||
saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.material-tone-info {
|
||||
@@ -393,9 +404,7 @@ input:focus-visible {
|
||||
z-index: 1;
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
||||
0 10px 24px rgba(15, 23, 42, 0.07);
|
||||
box-shadow: inset 0 -1px 0 var(--surface-divider);
|
||||
}
|
||||
|
||||
.agent-panel-band {
|
||||
@@ -533,12 +542,44 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.scheduled-feed-panel-shell {
|
||||
--scheduled-feed-timeline-width: calc(
|
||||
var(--workbench-condition-width) - 1.5rem - 2px
|
||||
);
|
||||
transition:
|
||||
width 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
max-height 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.scheduled-feed-panel-expanded {
|
||||
width: min(
|
||||
var(--workbench-condition-expanded-width),
|
||||
calc(100vw - var(--workbench-agent-current-width) - 100px)
|
||||
);
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.scheduled-feed-panel-expanded {
|
||||
width: min(
|
||||
var(--workbench-condition-expanded-width-wide),
|
||||
calc(100vw - var(--workbench-agent-current-width) - 100px)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.scheduled-feed-mobile {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-mobile .scheduled-feed-detail-scroll {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.mobile-workbench-sheet {
|
||||
overscroll-behavior: contain;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.scheduled-feed-detail-enter {
|
||||
animation: scheduled-feed-detail-show 140ms ease-out 180ms both;
|
||||
will-change: opacity;
|
||||
@@ -554,7 +595,9 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.scheduled-feed-layout-expanded {
|
||||
grid-template-columns: minmax(0, 1fr) 432px;
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr)
|
||||
var(--scheduled-feed-timeline-width);
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 the 620px workspace limit", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
@@ -16,13 +18,15 @@ test("Agent panel resizes by drag without exceeding half the viewport", async ({
|
||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(620);
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
|
||||
await page.keyboard.press("End");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(620);
|
||||
});
|
||||
|
||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
||||
@@ -60,7 +64,7 @@ test.describe("mobile touch layout", () => {
|
||||
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);
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,5 +77,5 @@ test("mobile alert summary opens the Agent conversation panel", async ({ page })
|
||||
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);
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test.describe("mobile workbench sheet", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("supports all snap heights, keyboard control, and Escape close", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
const handle = page.getByRole("button", { name: /Agent 工作台抽屉高度/ });
|
||||
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
sheet.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const nestedFilters = Array.from(element.querySelectorAll("*")).filter(
|
||||
(descendant) => getComputedStyle(descendant).backdropFilter !== "none"
|
||||
);
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
nestedFilterCount: nestedFilters.length,
|
||||
opacity: style.opacity,
|
||||
transform: style.transform
|
||||
};
|
||||
})
|
||||
)
|
||||
.toEqual({
|
||||
backdropFilter: "blur(24px) saturate(1.08)",
|
||||
nestedFilterCount: 0,
|
||||
opacity: "1",
|
||||
transform: "none"
|
||||
});
|
||||
|
||||
await handle.click();
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(776);
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
|
||||
|
||||
await handle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "peek");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(96);
|
||||
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await page.keyboard.press("End");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 700 });
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(632);
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toBeHidden();
|
||||
});
|
||||
|
||||
test("keeps Agent and condition sheets mutually exclusive", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
await expect(page.getByRole("region", { name: "工况任务抽屉" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("offers a touch close action for the condition sheet", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭工况任务抽屉" }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
|
||||
await sheet.getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
|
||||
await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toHaveCount(0);
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,77 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
const timeline = panel.getByTestId("scheduled-condition-timeline");
|
||||
const collapsedPanelBox = await panel.boundingBox();
|
||||
const collapsedTimelineBox = await timeline.boundingBox();
|
||||
expect(collapsedPanelBox).not.toBeNull();
|
||||
expect(collapsedTimelineBox).not.toBeNull();
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
const expandedPanelBox = await panel.boundingBox();
|
||||
const expandedTimelineBox = await timeline.boundingBox();
|
||||
expect(expandedPanelBox).not.toBeNull();
|
||||
expect(expandedTimelineBox).not.toBeNull();
|
||||
expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe(
|
||||
Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width)
|
||||
);
|
||||
expect(Math.round(expandedTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.width)
|
||||
);
|
||||
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
|
||||
);
|
||||
|
||||
await panel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
const restoredTimelineBox = await timeline.boundingBox();
|
||||
expect(restoredTimelineBox).not.toBeNull();
|
||||
expect(Math.round(restoredTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.width)
|
||||
);
|
||||
});
|
||||
|
||||
test("narrow desktop keeps Agent and expanded conditions from overlapping", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(agentPanel).toHaveCSS("width", "484px");
|
||||
|
||||
const compactAgentBox = await agentPanel.boundingBox();
|
||||
const compactConditionBox = await panel.boundingBox();
|
||||
expect(compactAgentBox).not.toBeNull();
|
||||
expect(compactConditionBox).not.toBeNull();
|
||||
expect(compactConditionBox!.x - (compactAgentBox!.x + compactAgentBox!.width)).toBeGreaterThanOrEqual(
|
||||
24
|
||||
);
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
|
||||
await expect(panel).toHaveCSS("width", "852px");
|
||||
|
||||
const expandedConditionBox = await panel.boundingBox();
|
||||
const collapsedAgentBox = await page
|
||||
.locator('aside[aria-label="Agent 折叠栏"]')
|
||||
.boundingBox();
|
||||
expect(expandedConditionBox).not.toBeNull();
|
||||
expect(collapsedAgentBox).not.toBeNull();
|
||||
expect(
|
||||
expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width)
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
|
||||
await panel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
});
|
||||
|
||||
async function expectAcrylicComposition(panel: Locator) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(window.__waterNetworkMap?.getLayer("scada-hit"))
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => {
|
||||
const map = window.__waterNetworkMap;
|
||||
document.documentElement.dataset.panelCameraMoveCount = "0";
|
||||
map?.on("movestart", () => {
|
||||
const currentCount = Number(
|
||||
document.documentElement.dataset.panelCameraMoveCount ?? "0"
|
||||
);
|
||||
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.dataset.panelCameraMoveCount
|
||||
)
|
||||
).toBe("0");
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { expect, test, type Locator, type Page } from "playwright/test";
|
||||
|
||||
const EMPTY_RASTER_TILE = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
test.describe("neutral blue mist workbench", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await prepareDeterministicWorkbench(page);
|
||||
});
|
||||
|
||||
test("desktop light basemap keeps both floating panels legible", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await openWorkbench(page);
|
||||
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.64,
|
||||
panel: 0.56,
|
||||
control: 0.74
|
||||
});
|
||||
await expectLoadedChineseFont(page);
|
||||
await expectBodyContrast(page);
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await openWorkbench(page);
|
||||
await selectBasemap(page, "影像");
|
||||
|
||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.8,
|
||||
panel: 0.72,
|
||||
control: 0.86
|
||||
});
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
|
||||
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await openWorkbench(page);
|
||||
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(agentPanel).toHaveCSS("width", "620px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 768);
|
||||
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(conditionPanel).toHaveCSS("width", "960px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 260);
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile light basemap shows the Agent half sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openWorkbench(page);
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
||||
await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile satellite basemap shows the condition half sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openWorkbench(page);
|
||||
await selectBasemap(page, "影像");
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||
await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareDeterministicWorkbench(page: Page) {
|
||||
await page.route("**/runtime-config.js", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
};`
|
||||
});
|
||||
});
|
||||
await page.route("https://api.mapbox.com/**", async (route) => {
|
||||
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
|
||||
});
|
||||
await page.route("https://visual-map.invalid/**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/vnd.mapbox-vector-tile",
|
||||
body: Buffer.alloc(0)
|
||||
});
|
||||
});
|
||||
await page.route("https://demotiles.maplibre.org/**", async (route) => {
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
});
|
||||
await page.route("**/*.riv", async (route) => {
|
||||
await route.abort();
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkbench(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.locator("main").evaluate((element) => {
|
||||
element.setAttribute("data-visual-test", "true");
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
[data-sonner-toaster] { display: none !important; }
|
||||
[data-visual-test] .maplibregl-canvas { opacity: 0.28; }
|
||||
[data-visual-test][data-basemap-tone="light"] .map-grid {
|
||||
background:
|
||||
radial-gradient(circle at 70% 32%, rgba(56, 120, 170, 0.18) 0 3px, transparent 4px),
|
||||
linear-gradient(118deg, transparent 0 46%, rgba(65, 121, 160, 0.12) 46.2% 46.7%, transparent 47%),
|
||||
linear-gradient(90deg, rgba(75, 124, 160, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(rgba(75, 124, 160, 0.06) 1px, transparent 1px),
|
||||
#dce8ef;
|
||||
background-size: auto, auto, 48px 48px, 48px 48px, auto;
|
||||
}
|
||||
[data-visual-test][data-basemap-tone="satellite"] .map-grid {
|
||||
background:
|
||||
radial-gradient(circle at 68% 30%, rgba(183, 207, 159, 0.24) 0 4px, transparent 5px),
|
||||
linear-gradient(126deg, transparent 0 44%, rgba(178, 195, 153, 0.2) 44.2% 44.8%, transparent 45%),
|
||||
repeating-linear-gradient(24deg, rgba(34, 67, 58, 0.18) 0 1px, transparent 1px 22px),
|
||||
#526961;
|
||||
}
|
||||
`
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.waitForTimeout(350);
|
||||
}
|
||||
|
||||
async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
await page.getByRole("button", { name: label, exact: true }).click();
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
await page.waitForTimeout(220);
|
||||
}
|
||||
|
||||
async function expectDesktopFloatingGeometry(page: Page) {
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
|
||||
await expect(agentPanel).toHaveCSS("width", "460px");
|
||||
await expect(conditionPanel).toHaveCSS("width", "432px");
|
||||
await expect(agentPanel).toHaveCSS("border-radius", "16px");
|
||||
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
|
||||
|
||||
const agentBox = await agentPanel.boundingBox();
|
||||
const conditionBox = await conditionPanel.boundingBox();
|
||||
expect(agentBox).not.toBeNull();
|
||||
expect(conditionBox).not.toBeNull();
|
||||
expect(agentBox!.x).toBe(12);
|
||||
expect(agentBox!.y).toBe(96);
|
||||
expect(agentBox!.y + agentBox!.height).toBe(884);
|
||||
expect(conditionBox!.x).toBe(944);
|
||||
expect(conditionBox!.y).toBe(96);
|
||||
}
|
||||
|
||||
async function expectMapCorridor(
|
||||
agentPanel: Locator,
|
||||
conditionPanel: Locator,
|
||||
minimumWidth: number
|
||||
) {
|
||||
const agentBox = await agentPanel.boundingBox();
|
||||
const conditionBox = await conditionPanel.boundingBox();
|
||||
expect(agentBox).not.toBeNull();
|
||||
expect(conditionBox).not.toBeNull();
|
||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(
|
||||
minimumWidth
|
||||
);
|
||||
}
|
||||
|
||||
async function expectLoadedChineseFont(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.fonts.check('16px "Noto Sans SC Variable"', "供水管网"))
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectAcrylicAlphas(
|
||||
page: Page,
|
||||
expected: { navigation: number; panel: number; control: number }
|
||||
) {
|
||||
const alphas = await page.evaluate(() => {
|
||||
return {
|
||||
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
|
||||
panel: alphaOf(document.querySelector(".acrylic-panel")),
|
||||
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.panel).toBeCloseTo(expected.panel, 2);
|
||||
expect(alphas.control).toBeCloseTo(expected.control, 2);
|
||||
}
|
||||
|
||||
async function expectBodyContrast(page: Page) {
|
||||
const contrast = await page
|
||||
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
.evaluate((element) => {
|
||||
const foreground = parseColor(getComputedStyle(element).color);
|
||||
const background = { r: 250, g: 252, b: 254 };
|
||||
return contrastRatio(foreground, background);
|
||||
|
||||
function parseColor(color: string) {
|
||||
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [0, 0, 0];
|
||||
return { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 };
|
||||
}
|
||||
|
||||
function contrastRatio(
|
||||
first: { r: number; g: number; b: number },
|
||||
second: { r: number; g: number; b: number }
|
||||
) {
|
||||
const lighter = Math.max(luminance(first), luminance(second));
|
||||
const darker = Math.min(luminance(first), luminance(second));
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function luminance(color: { r: number; g: number; b: number }) {
|
||||
const [r, g, b] = [color.r, color.g, color.b].map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
expect(contrast).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 470 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Reference in New Issue
Block a user