Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1f7ca7a9 | ||
|
|
aa9b12662e | ||
|
|
b948551c1f | ||
|
|
e0fe4a5a10 |
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
|
||||
|
||||
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
|
||||
|
||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||
|
||||
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
|
||||
|
||||
## 2. Tailwind 定制方式
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
||||
const start = index(proposed[0], labels.length);
|
||||
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
|
||||
const hidden = indices(state.hidden, series.length);
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
|
||||
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
|
||||
const data = answerChartTableData(node, chart);
|
||||
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
|
||||
|
||||
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(shortText).min(1).max(100),
|
||||
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
|
||||
},
|
||||
Evidence: {
|
||||
props: z
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
chart_type: "line" | "bar" | "time_comparison";
|
||||
x_data: string[];
|
||||
series: SafeChartSeries[];
|
||||
x_axis_name?: string;
|
||||
@@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
const numberLabel = chartValueLabel;
|
||||
const option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
const chartSchema = z
|
||||
.object({
|
||||
title: z.string().max(200),
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
|
||||
|
||||
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it("keeps initial healthy sources quiet and announces recovery once", () => {
|
||||
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
|
||||
expect(showMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
|
||||
expect(showMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
|
||||
expect(showMapNotice).toHaveBeenCalledTimes(2);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("updates a degraded source in place and only removes it when unmounted", () => {
|
||||
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
|
||||
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
|
||||
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
|
||||
expect(showMapNotice).toHaveBeenCalledTimes(3);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledWith("source");
|
||||
});
|
||||
|
||||
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
|
||||
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
|
||||
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapErrorNotice id="new" message="另一项错误" />);
|
||||
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
|
||||
});
|
||||
|
||||
it("keeps loading visible while its title or message changes", () => {
|
||||
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
|
||||
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type CSSProperties } from "react";
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
@@ -17,10 +17,11 @@ export function MapToaster() {
|
||||
position="top-center"
|
||||
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
|
||||
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
|
||||
swipeDirections={[]}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast: "group pointer-events-none",
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export function MapToaster() {
|
||||
}
|
||||
|
||||
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
|
||||
message,
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message]);
|
||||
|
||||
return null;
|
||||
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
|
||||
title?: string;
|
||||
id?: string | number;
|
||||
}) {
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
|
||||
duration: Infinity,
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message, title]);
|
||||
|
||||
return null;
|
||||
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapSourceStatusNoticeProps) {
|
||||
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
const last = previous.current;
|
||||
previous.current = { id, status };
|
||||
// Initial healthy sources and repeated tile-load messages stay quiet.
|
||||
// Success is announced when the same source recovers from a problem.
|
||||
if (status === "online" && !(actionLabel && onAction)
|
||||
&& (!last || last.id !== id || last.status === "online")) return;
|
||||
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: getSourceStatusTone(status),
|
||||
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
|
||||
actionLabel,
|
||||
onAction
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useNoticeCleanup(id: string | number) {
|
||||
// Content updates reuse the toast id. Dismissing during an update races
|
||||
// Sonner's asynchronous removal against the newly published content.
|
||||
useEffect(() => () => dismissMapNotice(id), [id]);
|
||||
}
|
||||
|
||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||
if (status === "online") {
|
||||
return "success";
|
||||
|
||||
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
|
||||
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
|
||||
...(generation === undefined ? {} : { execution_generation: generation }) };
|
||||
return {
|
||||
execution_scope: "production",
|
||||
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "waiting_human", active_run: null,
|
||||
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
|
||||
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("operational task contract", () => {
|
||||
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
|
||||
"loads %s summaries and details with the Server execution scope", (taskType) => {
|
||||
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
|
||||
const {
|
||||
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
|
||||
actions: _actions, permissions: _permissions, ...summary
|
||||
} = detail;
|
||||
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
|
||||
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
|
||||
}
|
||||
);
|
||||
|
||||
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
|
||||
const detail = { ...task(), execution_scope: scope };
|
||||
const {
|
||||
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
|
||||
actions: _actions, permissions: _permissions, ...summary
|
||||
} = detail;
|
||||
expect(() => parseOperationalTaskSummary(summary)).toThrow();
|
||||
expect(() => parseOperationalTask(detail)).toThrow();
|
||||
});
|
||||
|
||||
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
|
||||
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
|
||||
|
||||
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
|
||||
it("keeps unknown fields and invalid generations rejected", () => {
|
||||
expect(() => parseOperationalTask(task(0))).toThrow();
|
||||
const value = task();
|
||||
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
|
||||
|
||||
@@ -50,6 +50,7 @@ const operationalTaskRunPageSchema = z.object({
|
||||
next_cursor: z.string().min(1).nullable()
|
||||
}).strict();
|
||||
const taskSummarySchema = z.object({
|
||||
execution_scope: z.string().min(1),
|
||||
handling_context: z.record(z.unknown()).optional(),
|
||||
project_id: z.string().uuid(),
|
||||
task_id: z.string().uuid(),
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
|
||||
});
|
||||
|
||||
const taskBase = {
|
||||
execution_scope: "production",
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: "event_investigation",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
function task(taskType: OperationalTask["task_type"]): OperationalTask {
|
||||
return {
|
||||
execution_scope: "production",
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: taskType,
|
||||
|
||||
@@ -416,9 +416,8 @@ export function useWorkbenchMap({
|
||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||
});
|
||||
|
||||
map.on("sourcedataabort", (event) => {
|
||||
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
|
||||
});
|
||||
// Moving or zooming cancels obsolete tile requests. Cancellation is part
|
||||
// of navigation; source failures are reported by the error handler below.
|
||||
|
||||
map.on("error", (event) => {
|
||||
const message = event.error?.message ?? "地图渲染异常";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
|
||||
import { useFloatingWindowDrag } from "./use-floating-window-drag";
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number };
|
||||
|
||||
@@ -48,16 +49,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
||||
};
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
|
||||
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
|
||||
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||
const step = headingHeight + 8;
|
||||
const minHeight = Math.min(260, window.innerHeight - 16);
|
||||
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
|
||||
const slot = others.length % slots;
|
||||
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
|
||||
// Keep the preceding headings exposed. Shorter viewports reduce content
|
||||
// height instead of clamping several windows onto the same title bar.
|
||||
// Absolute placement also makes StrictMode's repeated setup idempotent.
|
||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
|
||||
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => fitWindow(current));
|
||||
setBounds((current) => {
|
||||
const minimumHeight = Math.min(260, window.innerHeight - 16);
|
||||
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
|
||||
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
return () => {
|
||||
@@ -69,15 +85,16 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
||||
const current = maximized
|
||||
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
|
||||
: bounds;
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
|
||||
width: current.width, height: current.height,
|
||||
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
|
||||
|
||||
return <>
|
||||
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
|
||||
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (!open) {
|
||||
const offset = (order.length % 5) * 24;
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
@@ -89,8 +106,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
||||
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
|
||||
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
|
||||
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
|
||||
disableDragging={maximized} enableResizing={!maximized}
|
||||
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
|
||||
disableDragging enableResizing={!maximized}
|
||||
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
|
||||
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
|
||||
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
|
||||
@@ -110,7 +126,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||
}}>
|
||||
<div ref={heading} tabIndex={0}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onPointerDown={startDrag}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
/** Move only the active DOM layer per frame; persist React bounds on release. */
|
||||
export function useFloatingWindowDrag({ enabled, position, width, height, onCommit }: {
|
||||
enabled: boolean;
|
||||
position: Position;
|
||||
width: number;
|
||||
height: number;
|
||||
onCommit: (position: Position) => void;
|
||||
}) {
|
||||
const cancel = useRef<(() => void) | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const stop = () => cancel.current?.();
|
||||
window.addEventListener("resize", stop);
|
||||
return () => {
|
||||
stop();
|
||||
window.removeEventListener("resize", stop);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current
|
||||
|| (event.target as Element).closest("button,input,select,textarea,a")) return;
|
||||
const heading = event.currentTarget;
|
||||
const element = heading.closest<HTMLElement>("[data-floating-window]");
|
||||
if (!element) return;
|
||||
event.preventDefault();
|
||||
heading.focus({ preventScroll: true });
|
||||
const pointerId = event.pointerId;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const maxX = window.innerWidth - width - 8;
|
||||
const maxY = window.innerHeight - height - 8;
|
||||
const previousTranslate = element.style.translate;
|
||||
const previousWillChange = element.style.willChange;
|
||||
let next = position;
|
||||
let frame = 0;
|
||||
element.style.willChange = "translate";
|
||||
const updatePosition = (pointer: PointerEvent) => {
|
||||
next = {
|
||||
x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)),
|
||||
y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY))
|
||||
};
|
||||
};
|
||||
const move = (pointer: PointerEvent) => {
|
||||
if (pointer.pointerId !== pointerId) return;
|
||||
updatePosition(pointer);
|
||||
if (!frame) frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
element.style.translate = `${next.x - position.x}px ${next.y - position.y}px`;
|
||||
});
|
||||
};
|
||||
const cleanup = () => {
|
||||
cancel.current = null;
|
||||
cancelAnimationFrame(frame);
|
||||
heading.removeEventListener("pointermove", move);
|
||||
heading.removeEventListener("pointerup", finish);
|
||||
heading.removeEventListener("pointercancel", abort);
|
||||
heading.removeEventListener("lostpointercapture", abort);
|
||||
element.style.translate = previousTranslate;
|
||||
element.style.willChange = previousWillChange;
|
||||
if (heading.hasPointerCapture(pointerId)) heading.releasePointerCapture(pointerId);
|
||||
};
|
||||
const abort = (pointer: PointerEvent) => {
|
||||
if (pointer.pointerId === pointerId) cleanup();
|
||||
};
|
||||
const finish = (pointer: PointerEvent) => {
|
||||
if (pointer.pointerId !== pointerId) return;
|
||||
updatePosition(pointer);
|
||||
// Commit and remove the temporary offset in one turn to avoid a snap.
|
||||
if (next.x !== position.x || next.y !== position.y) flushSync(() => onCommit(next));
|
||||
cleanup();
|
||||
};
|
||||
cancel.current = cleanup;
|
||||
heading.addEventListener("pointermove", move);
|
||||
heading.addEventListener("pointerup", finish);
|
||||
heading.addEventListener("pointercancel", abort);
|
||||
heading.addEventListener("lostpointercapture", abort);
|
||||
heading.setPointerCapture(pointerId);
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
height: min(720px, 80dvh);
|
||||
max-width: 760px;
|
||||
}
|
||||
/* Layout only: keep the shared acrylic material and its shadows unclipped. */
|
||||
/* Flow layout owns stack geometry; keep Sonner opacity transitions without its absolute offsets. */
|
||||
[data-sonner-toaster].map-notice-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
bottom: auto;
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
--y: translateY(0);
|
||||
transform: none;
|
||||
}
|
||||
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
|
||||
display: none;
|
||||
|
||||
@@ -61,6 +61,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
]
|
||||
};
|
||||
let task: OperationalTask = {
|
||||
execution_scope: "production",
|
||||
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
|
||||
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
|
||||
|
||||
@@ -8,6 +8,180 @@ const chart = (index: number) => ({
|
||||
});
|
||||
|
||||
test.setTimeout(60_000);
|
||||
// Keep unrelated avatar animation out of chart geometry and drag measurements.
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => {
|
||||
const task = investigationFixture(1, "图窗拖动取消核查");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
await mock.emit(task, chart(1));
|
||||
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
|
||||
await expect(popup).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
const heading = popup.locator(".floating-window-heading");
|
||||
await heading.click({ trial: true, position: { x: 200, y: 24 } });
|
||||
const original = (await popup.boundingBox())!;
|
||||
await heading.evaluate((node) => node.addEventListener("pointerdown", (event) => {
|
||||
(node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId);
|
||||
}, { once: true }));
|
||||
await page.mouse.move(original.x + 200, original.y + 24);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(original.x + 100, original.y + 64, { steps: 4 });
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.x)).toBe(original.x - 100);
|
||||
await heading.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", {
|
||||
pointerId: Number((node as HTMLElement).dataset.testPointerId)
|
||||
})));
|
||||
await page.mouse.up();
|
||||
expect(await popup.boundingBox()).toEqual(original);
|
||||
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
|
||||
await popup.getByRole("button", { name: "最大化窗口", exact: true }).click();
|
||||
expect((await popup.boundingBox())!.x).toBe(8);
|
||||
await popup.getByRole("button", { name: "还原窗口", exact: true }).click();
|
||||
expect(await popup.boundingBox()).toEqual(original);
|
||||
await heading.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
expect((await popup.boundingBox())!.x).toBe(original.x + 16);
|
||||
await page.mouse.move(original.x + 16 + original.width - 3, original.y + original.height - 3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(original.x + 16 + original.width + 37, original.y + original.height + 27, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
expect((await popup.boundingBox())!.width).toBe(original.width + 40);
|
||||
expect((await popup.boundingBox())!.height).toBe(original.height + 30);
|
||||
await heading.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popup).toHaveCount(0);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("profiles a single drag with eight comparison chart windows", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const task = investigationFixture(1, "多图拖动性能核查");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
const slots = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
|
||||
for (let index = 1; index <= 8; index++) {
|
||||
await mock.emit(task, { action: "open_chart", chart_id: `comparison-${index}`, evidence_refs: ["synthetic:pressure"], chart: {
|
||||
title: `合成七日同期曲线 ${index}`, chart_type: "time_comparison", x_data: slots,
|
||||
series: [{ name: "当前", type: "line", data: slots.map((_, point) => 30 - index + point / 10),
|
||||
sample_times: slots.map((time) => `2026-07-16T${time}:00+08:00`) },
|
||||
...Array.from({ length: 7 }, (_, day) => ({ name: `前${day + 1}天`, type: "scatter",
|
||||
data: slots.map((_, point) => point < 10 ? null : 30 + day / 10),
|
||||
sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))]
|
||||
} });
|
||||
await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(8);
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const popup = page.getByRole("dialog", { name: "合成七日同期曲线 8", exact: true });
|
||||
const otherWindows = page.locator('[data-floating-window]').filter({ hasNot: page.getByRole("heading", { name: "合成七日同期曲线 8", exact: true }) });
|
||||
const otherPositions = await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
|
||||
const { x, y } = node.getBoundingClientRect();
|
||||
return { x, y };
|
||||
}));
|
||||
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
|
||||
const before = (await popup.boundingBox())!;
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||
await cdp.send("Performance.enable");
|
||||
const first = await cdp.send("Performance.getMetrics");
|
||||
const events: Array<{ name: string; dur?: number }> = [];
|
||||
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value));
|
||||
await cdp.send("Tracing.start", { categories: "devtools.timeline", transferMode: "ReportEvents" });
|
||||
await page.evaluate(() => {
|
||||
Object.assign(window, { dragSvgMutations: 0, dragTransformWrites: 0 });
|
||||
const observer = new MutationObserver((records) => {
|
||||
(window as unknown as { dragSvgMutations: number }).dragSvgMutations += records.length;
|
||||
});
|
||||
document.querySelectorAll("[data-floating-window] svg").forEach((node) => observer.observe(node, { subtree: true, attributes: true, childList: true }));
|
||||
const windowObserver = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const previous = document.createElement("div");
|
||||
previous.setAttribute("style", record.oldValue ?? "");
|
||||
if (previous.style.transform !== (record.target as HTMLElement).style.transform)
|
||||
(window as unknown as { dragTransformWrites: number }).dragTransformWrites++;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll("[data-floating-window]").forEach((node) => windowObserver.observe(node, { attributes: true, attributeFilter: ["style"], attributeOldValue: true }));
|
||||
});
|
||||
const x = heading.x + heading.width - 110;
|
||||
const y = heading.y + heading.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x - 100, y - 60, { steps: 20 });
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-100);
|
||||
await page.mouse.move(x - 200, y - 120, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-200);
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-120);
|
||||
const last = await cdp.send("Performance.getMetrics");
|
||||
const finished = new Promise<void>((resolve) => cdp.once("Tracing.tracingComplete", () => resolve()));
|
||||
await cdp.send("Tracing.end");
|
||||
await finished;
|
||||
const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0);
|
||||
const summary = { metrics: Object.fromEntries(["ScriptDuration", "LayoutDuration", "RecalcStyleDuration", "TaskDuration", "LayoutCount", "RecalcStyleCount"].map((name) => [name, metric(name)])),
|
||||
trace: Object.fromEntries(["Paint", "Layout", "UpdateLayoutTree", "FunctionCall"].map((name) => [name, { count: events.filter((event) => event.name === name).length,
|
||||
ms: events.filter((event) => event.name === name).reduce((total, event) => total + (event.dur ?? 0), 0) / 1000 }])),
|
||||
svgMutations: await page.evaluate(() => (window as unknown as { dragSvgMutations: number }).dragSvgMutations),
|
||||
transformWrites: await page.evaluate(() => (window as unknown as { dragTransformWrites: number }).dragTransformWrites) };
|
||||
await testInfo.attach("drag-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
|
||||
console.info("Window drag metrics", JSON.stringify(summary));
|
||||
expect(summary.transformWrites).toBeLessThanOrEqual(2);
|
||||
expect(summary.svgMutations).toBe(0);
|
||||
expect(await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
|
||||
const { x, y } = node.getBoundingClientRect();
|
||||
return { x, y };
|
||||
}))).toEqual(otherPositions);
|
||||
await testInfo.attach("multiple-charts-after-drag", { body: await page.screenshot({ path: testInfo.outputPath("multiple-charts-after-drag.png") }), contentType: "image/png" });
|
||||
});
|
||||
|
||||
for (const height of [900, 720]) {
|
||||
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
const task = investigationFixture(1, "多图窗口拖动核查");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
for (let index = 1; index <= 4; index++) {
|
||||
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
|
||||
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
|
||||
}
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
|
||||
for (let index = 1; index <= 4; index++) {
|
||||
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||
const heading = popup.locator(".floating-window-heading");
|
||||
const box = (await heading.boundingBox())!;
|
||||
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
|
||||
await expect.poll(async () => {
|
||||
const current = (await heading.boundingBox())!;
|
||||
return page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"),
|
||||
{ x: current.x + current.width / 2, y: current.y + current.height / 2 });
|
||||
}).toBe(await popup.getAttribute("aria-labelledby"));
|
||||
}
|
||||
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
|
||||
});
|
||||
for (const index of [1, 4, 2]) {
|
||||
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||
const before = (await popup.boundingBox())!;
|
||||
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
|
||||
const start = { x: heading.x + heading.width - 110, y: heading.y + heading.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120);
|
||||
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-32);
|
||||
expect((await popup.boundingBox())!.width).toBe(before.width);
|
||||
expect((await popup.boundingBox())!.height).toBe(before.height);
|
||||
}
|
||||
await testInfo.attach(`multiple-charts-after-drag-${height}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-after-drag-${height}.png`) }), contentType: "image/png"
|
||||
});
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
|
||||
const task = investigationFixture(1, "图表自动展示核查");
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await mockInvestigationPresentation(page, []);
|
||||
});
|
||||
|
||||
async function openMap(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
}
|
||||
|
||||
async function clearInitialNotices(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||
(await import(path)).dismissMapNotice();
|
||||
});
|
||||
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("tile cancellation events during map navigation do not announce source outages", async ({ page }) => {
|
||||
await openMap(page);
|
||||
await clearInitialNotices(page);
|
||||
await page.evaluate(() => {
|
||||
const map = window.__waterNetworkMap!;
|
||||
Object.assign(window, { noticeTileAborts: 0, noticeFalseOutages: 0 });
|
||||
map.on("sourcedataabort", () => {
|
||||
const state = window as unknown as { noticeTileAborts: number };
|
||||
state.noticeTileAborts++;
|
||||
});
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) for (const node of record.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
for (const notice of [node, ...node.querySelectorAll("[data-sonner-toast]")]) {
|
||||
if (notice.matches("[data-sonner-toast]") && notice.textContent?.includes("GeoServer MVT 降级运行")) {
|
||||
(window as unknown as { noticeFalseOutages: number }).noticeFalseOutages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { subtree: true, childList: true });
|
||||
map.jumpTo({ zoom: 14 });
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.mouse.move(220, 420);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
// Deliver the same cancellation event MapLibre emits for obsolete tiles,
|
||||
// independently of cache timing or how quickly the fixture responds.
|
||||
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedataabort", { sourceId: "pipes" }));
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { noticeTileAborts: number }).noticeTileAborts)).toBeGreaterThan(0);
|
||||
expect(await page.evaluate(() => (window as unknown as { noticeFalseOutages: number }).noticeFalseOutages)).toBe(0);
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 降级运行" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a failed map source recovers in the same visible notice without entering removal", async ({ page }) => {
|
||||
await openMap(page);
|
||||
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
|
||||
await page.waitForTimeout(350);
|
||||
await clearInitialNotices(page);
|
||||
await page.evaluate(() => window.__waterNetworkMap!.fire("error", { sourceId: "pipes", error: new Error("notice-regression-test") }));
|
||||
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveCSS("opacity", "1");
|
||||
const node = await notice.elementHandle();
|
||||
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
|
||||
await page.waitForTimeout(350);
|
||||
expect(await node!.evaluate((element) => ({
|
||||
connected: element.isConnected,
|
||||
removed: element.getAttribute("data-removed"),
|
||||
text: element.textContent
|
||||
}))).toMatchObject({ connected: true, removed: "false", text: expect.stringContaining("GeoServer MVT 已连接") });
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("notice surfaces capture input above the task ticker while the outside map remains draggable", async ({ page }, testInfo) => {
|
||||
await openMap(page);
|
||||
await clearInitialNotices(page);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||
(await import(path)).showMapNotice({ id: "notice-input-regression", tone: "warning", title: "通知交互回归",
|
||||
message: "通知覆盖任务浮动条,滚轮与拖动由通知接收。", actionLabel: "查看任务", onAction: () => {} });
|
||||
});
|
||||
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "通知交互回归" });
|
||||
await expect(notice).toHaveCSS("opacity", "1");
|
||||
const ticker = page.locator(".agent-task-ticker");
|
||||
await expect(ticker).toBeVisible();
|
||||
const point = await page.evaluate(() => {
|
||||
const ticker = document.querySelector(".agent-task-ticker")!;
|
||||
const r = ticker.getBoundingClientRect();
|
||||
Object.assign(window, { noticeTickerWheelEvents: 0 });
|
||||
ticker.addEventListener("wheel", () => {
|
||||
(window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents++;
|
||||
});
|
||||
return { x: r.x + 170, y: r.y + 25 };
|
||||
});
|
||||
expect(await notice.evaluate((element, point) => element.contains(document.elementFromPoint(point.x, point.y)), point)).toBe(true);
|
||||
const before = await notice.boundingBox();
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.wheel(0, 120);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(point.x + 35, point.y + 15, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await expect(notice).toHaveCount(1);
|
||||
expect(await notice.boundingBox()).toEqual(before);
|
||||
expect(await page.evaluate(() => (window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents)).toBe(0);
|
||||
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
|
||||
await page.mouse.move(220, 420);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(330, 450, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
|
||||
await testInfo.attach("notice-above-ticker", { body: await page.screenshot({ path: testInfo.outputPath("notice-priority.png") }), contentType: "image/png" });
|
||||
await notice.getByRole("button", { name: "关闭通知" }).click();
|
||||
await expect(notice).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("multiple notices stay anchored while panning and source recovery exits without a downward jump", async ({ page }, testInfo) => {
|
||||
await openMap(page);
|
||||
await clearInitialNotices(page);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||
const notices = await import(path);
|
||||
notices.showMapNotice({ id: "scada-position-regression", tone: "error", title: "SCADA 位置回归",
|
||||
message: "监测设备暂不可用。", duration: Infinity });
|
||||
notices.showMapNotice({ id: "geoserver-position-regression", tone: "error", title: "GeoServer MVT 不可用",
|
||||
message: "地图数据源请求失败。", duration: Infinity });
|
||||
});
|
||||
const recovery = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
|
||||
await expect(recovery).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||
(await import(path)).showMapNotice({ id: "report-position-regression", title: "报告位置回归",
|
||||
message: "报告入口持续保留。", actionLabel: "查看报告", onAction: () => {} });
|
||||
});
|
||||
const scada = page.locator("[data-sonner-toast]").filter({ hasText: "SCADA 位置回归" });
|
||||
const report = page.locator("[data-sonner-toast]").filter({ hasText: "报告位置回归" });
|
||||
await expect(report).toHaveCSS("opacity", "1");
|
||||
await expect.poll(() => page.locator("[data-sonner-toaster]").evaluate((element) =>
|
||||
element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length
|
||||
)).toBe(0);
|
||||
const boxes = await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()]);
|
||||
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
|
||||
const track = await recovery.evaluateHandle((element) => {
|
||||
const cards = ["报告位置回归", "GeoServer MVT 不可用", "SCADA 位置回归"].map((title) =>
|
||||
[...document.querySelectorAll("[data-sonner-toast]")].find((card) => card.textContent?.includes(title))!);
|
||||
const state = { stopped: false, panning: true, panY: [] as number[][], liveY: [] as number[], exitY: [] as number[] };
|
||||
const sample = () => {
|
||||
if (state.stopped || !element.isConnected) return;
|
||||
if (state.panning) state.panY.push(cards.map((card) => card.getBoundingClientRect().y));
|
||||
const y = element.getBoundingClientRect().y;
|
||||
(element.getAttribute("data-removed") === "true" ? state.exitY : state.liveY).push(y);
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
return state;
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.mouse.move(220, 420);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
expect(await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
|
||||
expect(await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()])).toEqual(boxes);
|
||||
await track.evaluate((state) => { state.panning = false; });
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||
(await import(path)).showMapNotice({ id: "geoserver-position-regression", tone: "success", title: "GeoServer MVT 已连接",
|
||||
message: "地图数据源已恢复正常。", duration: 700 });
|
||||
});
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toHaveCount(0, { timeout: 7000 });
|
||||
const samples = await track.evaluate((state) => { state.stopped = true; return state; });
|
||||
expect(samples.panY.length).toBeGreaterThan(0);
|
||||
for (const positions of samples.panY) {
|
||||
positions.forEach((y, index) => expect(Math.abs(y - boxes[index]!.y)).toBeLessThan(1));
|
||||
}
|
||||
expect(samples.liveY.length).toBeGreaterThan(0);
|
||||
expect(samples.exitY.length).toBeGreaterThan(0);
|
||||
for (const y of [...samples.liveY, ...samples.exitY]) expect(y).toBeLessThanOrEqual(boxes[1]!.y + 1);
|
||||
expect((await report.boundingBox())!.y).toBe(boxes[0]!.y);
|
||||
const reportBox = (await report.boundingBox())!;
|
||||
const scadaBox = (await scada.boundingBox())!;
|
||||
expect(scadaBox.y).toBeCloseTo(reportBox.y + reportBox.height + 8, 0);
|
||||
await testInfo.attach("notice-positions-after-pan-and-recovery", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("notice-positions.png") }), contentType: "image/png"
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
|
||||
execution_detail: null
|
||||
};
|
||||
let task: OperationalTask = {
|
||||
execution_scope: "production",
|
||||
project_id: projectId,
|
||||
task_id: taskId,
|
||||
task_type: "event_investigation",
|
||||
|
||||
@@ -3,13 +3,13 @@ import type { OperationalTask, OperationalTaskSummary } from "../../src/features
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => {
|
||||
test("调查和预约任务接收 Server 返回的执行范围与空回放上下文并展示详情", async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const now = new Date().toISOString();
|
||||
const id = "72000000-0000-4000-8000-000000000001";
|
||||
const appointmentId = "72000000-0000-4000-8000-000000000002";
|
||||
const summary: OperationalTaskSummary = {
|
||||
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
|
||||
execution_scope: "production", project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
|
||||
task_type: "event_investigation", workflow: "burst_report",
|
||||
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
|
||||
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id,
|
||||
@@ -65,7 +65,7 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
|
||||
await panel.getByRole("button", { name: /调查任务/ }).click();
|
||||
await panel.getByText(summary.title, { exact: true }).first().click();
|
||||
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary);
|
||||
await testInfo.attach("investigation-loaded", { body: await panel.screenshot(), contentType: "image/png" });
|
||||
await testInfo.attach("investigation-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-loaded.png") }), contentType: "image/png" });
|
||||
|
||||
await panel.getByRole("button", { name: /预约任务/ }).click();
|
||||
await panel.getByText(appointment.title, { exact: true }).first().click();
|
||||
@@ -73,5 +73,5 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
|
||||
await expect(detail).toContainText("核对预约任务详情");
|
||||
await expect(detail).toContainText("凤阳维修组");
|
||||
await expect(detail).toContainText("凤阳核验点");
|
||||
await testInfo.attach("appointment-loaded", { body: await panel.screenshot(), contentType: "image/png" });
|
||||
await testInfo.attach("appointment-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("appointment-loaded.png") }), contentType: "image/png" });
|
||||
});
|
||||
|
||||
@@ -1267,6 +1267,7 @@ test.describe("外围系统", () => {
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
|
||||
const input = request.postDataJSON() as Record<string, unknown>;
|
||||
operationalTask = {
|
||||
execution_scope: "production",
|
||||
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
|
||||
task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "waiting_human", active_run: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ async function expectPushedOrder(page: Page, failFirstDetail = false) {
|
||||
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
|
||||
};
|
||||
const task = {
|
||||
execution_scope: "production",
|
||||
project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order",
|
||||
workflow: "external-work-order", lifecycle_status: "open", active_run: null,
|
||||
status: "queued", priority: "normal", revision: 2, execution_generation: 1,
|
||||
|
||||
@@ -9,8 +9,8 @@ const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor(
|
||||
const block = {
|
||||
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
|
||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||
title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)",
|
||||
x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)),
|
||||
title: "压力与前七天同时刻对照(m)", chart_type: "time_comparison", y_axis_name: "压力(m)", x_axis_name: "时间",
|
||||
x_data: times,
|
||||
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
|
||||
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
|
||||
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
|
||||
@@ -45,7 +45,7 @@ for (const width of [1440, 375, 320]) {
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const ui = panel.locator(".answer-ui-chart");
|
||||
await expect(ui).toBeVisible();
|
||||
await expect(ui).toContainText("时间(UTC+8)");
|
||||
await expect(ui.locator("svg text").filter({ hasText: /^时间$/ })).toBeVisible();
|
||||
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
|
||||
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
|
||||
// Each historical day has exactly three unconnected SVG scatter symbols.
|
||||
|
||||
@@ -11,6 +11,7 @@ export function investigationFixture(index: number, title: string): OperationalT
|
||||
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
return {
|
||||
execution_scope: "production",
|
||||
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
|
||||
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
|
||||
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",
|
||||
|
||||
Reference in New Issue
Block a user