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" + }); +});