diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5821346b1..a71e0d429 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2982,3 +2982,11 @@ - 原因:维护退出发生在随 API artifact 发布的 `production-api-deploy.sh` 内;Full、API Deploy Job 和脚本任一层没有透传,最终都会回到固定执行 `maintenance-off.sh`。Declarative Pipeline 参数还要等 live Job 加载新版 Jenkinsfile 后才会刷新。 - 处理:Full 使用 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 `KEEP_MAINTENANCE_MODE` 再转换为脚本 `--keep-maintenance-mode`。API deploy 还必须把 `production-api-deploy.sh`、`maintenance-on.sh` 和 `maintenance-off.sh` 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。 - 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 `config.xml`,不能只看仓库文件。 + +## 遮罩点击关闭必须校验完整指针序列 + +- 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。 +- 原因:只在 `click` 阶段判断 `event.target === event.currentTarget` 不足以确认用户点击了遮罩;跨弹窗边界松开时,浏览器可能把合成点击的目标归到弹窗和遮罩的共同祖先。 +- 处理:共享弹窗统一记录 `pointerdown` 与 `pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。 +- 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。 +- 关联:`src/components/common/UnifiedModal.tsx`、`src/components/common/UnifiedModal.test.tsx`、`src/components/auth/PlatformAuthModalShell.test.tsx`。 diff --git a/src/components/auth/PlatformAuthModalShell.test.tsx b/src/components/auth/PlatformAuthModalShell.test.tsx index 55765e8f7..41fa0aede 100644 --- a/src/components/auth/PlatformAuthModalShell.test.tsx +++ b/src/components/auth/PlatformAuthModalShell.test.tsx @@ -30,7 +30,15 @@ test('renders auth modal shell with platform theme and auth card chrome', () => expect(dialog.className).toContain('!max-w-md'); expect(within(dialog).getByText('登录表单')).toBeTruthy(); - fireEvent.click(dialog.parentElement as HTMLElement); + const backdrop = dialog.parentElement as HTMLElement; + fireEvent.pointerDown(within(dialog).getByText('登录表单')); + fireEvent.pointerUp(backdrop); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.pointerDown(backdrop); + fireEvent.pointerUp(backdrop); + fireEvent.click(backdrop); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/src/components/common/PlatformMudPointWalletEntry.test.tsx b/src/components/common/PlatformMudPointWalletEntry.test.tsx index 6aadc243b..1156a20d4 100644 --- a/src/components/common/PlatformMudPointWalletEntry.test.tsx +++ b/src/components/common/PlatformMudPointWalletEntry.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; @@ -60,6 +60,44 @@ test('shows only permanent and daily free points in the shared wallet panel', as expect(onRecharge).toHaveBeenCalledTimes(1); }); +test('keeps the desktop panel open while moving across the gap without click pinning', () => { + vi.useFakeTimers(); + + try { + render( + , + ); + + const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const root = balanceButton.closest('.platform-mud-point-wallet-entry'); + expect(root).toBeTruthy(); + + fireEvent.mouseEnter(balanceButton); + const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + + fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); + fireEvent.mouseEnter(details); + act(() => vi.advanceTimersByTime(120)); + expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + + balanceButton.focus(); + fireEvent.click(balanceButton); + expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + + fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); + act(() => vi.advanceTimersByTime(120)); + expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + test('requests the balance breakdown when a compact entry opens', async () => { const user = userEvent.setup(); const onRequestDetails = vi.fn(); diff --git a/src/components/common/PlatformMudPointWalletEntry.tsx b/src/components/common/PlatformMudPointWalletEntry.tsx index 9e74b519e..6f2b0f218 100644 --- a/src/components/common/PlatformMudPointWalletEntry.tsx +++ b/src/components/common/PlatformMudPointWalletEntry.tsx @@ -65,6 +65,7 @@ export function PlatformMudPointWalletEntry({ }: PlatformMudPointWalletEntryProps) { const rootRef = useRef(null); const isOpenRef = useRef(false); + const closeTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const isCompact = variant === 'mobile'; const displayedBalance = breakdown?.totalPoints ?? balance; @@ -75,12 +76,21 @@ export function PlatformMudPointWalletEntry({ const exactBalanceLabel = displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); - const closeDetails = useCallback(() => { - isOpenRef.current = false; - setIsOpen(false); + const cancelPendingClose = useCallback(() => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } }, []); + const closeDetails = useCallback(() => { + cancelPendingClose(); + isOpenRef.current = false; + setIsOpen(false); + }, [cancelPendingClose]); + const requestAndOpen = useCallback(() => { + cancelPendingClose(); if (isOpenRef.current) { return; } @@ -89,7 +99,9 @@ export function PlatformMudPointWalletEntry({ if (!breakdown && !isLoading) { onRequestDetails(); } - }, [breakdown, isLoading, onRequestDetails]); + }, [breakdown, cancelPendingClose, isLoading, onRequestDetails]); + + useEffect(() => cancelPendingClose, [cancelPendingClose]); useEffect(() => { if (!isOpen) { @@ -130,13 +142,10 @@ export function PlatformMudPointWalletEntry({ ) { return; } - window.setTimeout(() => { - if ( - rootRef.current && - !rootRef.current.contains(document.activeElement) - ) { - closeDetails(); - } + cancelPendingClose(); + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + closeDetails(); }, 120); }; return ( @@ -160,13 +169,17 @@ export function PlatformMudPointWalletEntry({ aria-expanded={isOpen} aria-haspopup="dialog" aria-busy={isLoading} - onClick={() => { - if (isOpenRef.current) { - closeDetails(); - return; - } - requestAndOpen(); - }} + onClick={ + isCompact + ? () => { + if (isOpenRef.current) { + closeDetails(); + return; + } + requestAndOpen(); + } + : undefined + } >
diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx index 7e1637764..adbca4290 100644 --- a/src/components/common/UnifiedModal.test.tsx +++ b/src/components/common/UnifiedModal.test.tsx @@ -39,6 +39,24 @@ test('closes through backdrop and escape', () => { expect(onClose).toHaveBeenCalledTimes(2); }); +test('keeps the modal open when a pointer press starts inside and releases over the backdrop', () => { + const onClose = vi.fn(); + render( + + + , + ); + + const dialog = screen.getByRole('dialog'); + const backdrop = dialog.parentElement as HTMLElement; + + fireEvent.pointerDown(screen.getByRole('button', { name: '窗口内容' })); + fireEvent.pointerUp(backdrop); + fireEvent.click(backdrop); + + expect(onClose).not.toHaveBeenCalled(); +}); + test('supports disabling escape close while keeping the custom close button chrome', () => { const onClose = vi.fn(); render( diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index 6129beab1..93b63f894 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -4,6 +4,7 @@ import { type ReactNode, useEffect, useId, + useRef, } from 'react'; import { createPortal } from 'react-dom'; @@ -118,6 +119,7 @@ function UnifiedModalContent({ const generatedTitleId = useId(); const descriptionId = useId(); const titleId = titleIdProp ?? generatedTitleId; + const backdropPointerSequenceRef = useRef(null); useEffect(() => { if (!open || closeDisabled || !closeOnEscape) { @@ -175,10 +177,26 @@ function UnifiedModalContent({
{ + backdropPointerSequenceRef.current = + event.target === event.currentTarget; + }} + onPointerUpCapture={(event) => { + backdropPointerSequenceRef.current = + backdropPointerSequenceRef.current === true && + event.target === event.currentTarget; + }} + onPointerCancelCapture={() => { + backdropPointerSequenceRef.current = false; + }} onClick={(event) => { + const pointerSequenceStayedOnBackdrop = + backdropPointerSequenceRef.current !== false; + backdropPointerSequenceRef.current = null; if ( closeOnBackdrop && !closeDisabled && + pointerSequenceStayedOnBackdrop && event.target === event.currentTarget ) { onClose();