修复泥点悬浮窗与登录弹窗交互
桌面端泥点详情支持从按钮移动到浮层并取消点击保持 统一弹窗仅在完整遮罩指针序列后关闭 补充泥点悬浮与登录遮罩拖出回归测试 记录遮罩关闭交互约束
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user