From b948551c1fe3b2cbd63b1029a6b888847786bf2a Mon Sep 17 00:00:00 2001 From: Huarch Date: Sun, 8 Nov 2026 03:23:00 +0800 Subject: [PATCH] fix(map): stabilize notifications during map navigation Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal. The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage. --- docs/style-design-guide.md | 4 +- .../map/core/components/notice.test.tsx | 52 +++++ src/features/map/core/components/notice.tsx | 28 ++- .../workbench/hooks/use-workbench-map.ts | 5 +- src/styles.css | 4 +- tests/browser/map-notice-stability.e2e.ts | 193 ++++++++++++++++++ 6 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 src/features/map/core/components/notice.test.tsx create mode 100644 tests/browser/map-notice-stability.e2e.ts diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md index 4e880bb..bb31c66 100644 --- a/docs/style-design-guide.md +++ b/docs/style-design-guide.md @@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、 通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。 -通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。 +通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。 + +通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。 ## 2. Tailwind 定制方式 diff --git a/src/features/map/core/components/notice.test.tsx b/src/features/map/core/components/notice.test.tsx new file mode 100644 index 0000000..e8f9421 --- /dev/null +++ b/src/features/map/core/components/notice.test.tsx @@ -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(); + expect(showMapNotice).not.toHaveBeenCalled(); + rerender(); + expect(showMapNotice).not.toHaveBeenCalled(); + rerender(); + expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" })); + rerender(); + expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 })); + rerender(); + 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(); + rerender(); + rerender(); + 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(); + rerender(); + expect(dismissMapNotice).not.toHaveBeenCalled(); + rerender(); + expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old"); + unmount(); + expect(dismissMapNotice).toHaveBeenLastCalledWith("new"); +}); + +it("keeps loading visible while its title or message changes", () => { + const { rerender, unmount } = render(); + rerender(); + expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity })); + expect(dismissMapNotice).not.toHaveBeenCalled(); + unmount(); + expect(dismissMapNotice).toHaveBeenCalledWith("loading"); +}); diff --git a/src/features/map/core/components/notice.tsx b/src/features/map/core/components/notice.tsx index e2df3f2..f594b02 100644 --- a/src/features/map/core/components/notice.tsx +++ b/src/features/map/core/components/notice.tsx @@ -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"; diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts index 41eac2b..9c7c3ae 100644 --- a/src/features/workbench/hooks/use-workbench-map.ts +++ b/src/features/workbench/hooks/use-workbench-map.ts @@ -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 ?? "地图渲染异常"; diff --git a/src/styles.css b/src/styles.css index f3b1395..37b9f5b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/tests/browser/map-notice-stability.e2e.ts b/tests/browser/map-notice-stability.e2e.ts new file mode 100644 index 0000000..944f695 --- /dev/null +++ b/tests/browser/map-notice-stability.e2e.ts @@ -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" + }); +});