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