修复泥点悬浮窗与登录弹窗交互

桌面端泥点详情支持从按钮移动到浮层并取消点击保持
统一弹窗仅在完整遮罩指针序列后关闭
补充泥点悬浮与登录遮罩拖出回归测试
记录遮罩关闭交互约束
This commit is contained in:
2026-07-13 12:47:40 +08:00
parent 184d7e24c9
commit 7becd38117
6 changed files with 124 additions and 20 deletions
@@ -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`
@@ -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);
});
@@ -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(
<PlatformMudPointWalletEntry
balance={207}
breakdown={breakdown}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
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();
@@ -65,6 +65,7 @@ export function PlatformMudPointWalletEntry({
}: PlatformMudPointWalletEntryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const isOpenRef = useRef(false);
const closeTimerRef = useRef<number | null>(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
}
>
<img
src={MUD_POINT_ICON_SRC}
@@ -199,6 +212,7 @@ export function PlatformMudPointWalletEntry({
<div
role="dialog"
aria-label="泥点账户详情"
onMouseEnter={isCompact ? undefined : cancelPendingClose}
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.12rem] border border-[var(--platform-subpanel-border)] bg-[#fffaf4] text-left shadow-[0_1rem_2.8rem_rgba(76,44,27,0.2)]"
>
<div className="flex items-center justify-between gap-3 px-4 py-3">
@@ -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(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<button type="button"></button>
</UnifiedModal>,
);
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(
+18
View File
@@ -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<boolean | null>(null);
useEffect(() => {
if (!open || closeDisabled || !closeOnEscape) {
@@ -175,10 +177,26 @@ function UnifiedModalContent({
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
style={overlayStyle}
onPointerDownCapture={(event) => {
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();