From 484b49d79d3fbe540e3723cff19db95f6df27623 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 6 Aug 2026 13:08:43 +0800 Subject: [PATCH] fix(frontend): replace browser confirm dialogs with AlertDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免 window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。 --- docs/changelog/index.md | 1 + .../admin/push/components/events-tab.tsx | 47 ++++++- .../admin/push/components/settings-tab.tsx | 47 ++++++- .../components/unsaved-changes.test.tsx | 67 ++++++---- .../editor/components/unsaved-changes.tsx | 120 ++++++++++++++++-- frontend/app/(main)/waf/rules/editor/page.tsx | 33 ++++- .../common/settings/access-token.tsx | 116 +++++++++++------ .../common/settings/security-tab.tsx | 51 ++++++-- .../components/common/settings/templates.tsx | 47 ++++++- 9 files changed, 429 insertions(+), 100 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6fb24ba0..ed651016 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -28,6 +28,7 @@ sidebar: false ### 改进 +- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。 - Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。 - Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。 - Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。 diff --git a/frontend/app/(main)/admin/push/components/events-tab.tsx b/frontend/app/(main)/admin/push/components/events-tab.tsx index 79f52e92..a1b22cef 100644 --- a/frontend/app/(main)/admin/push/components/events-tab.tsx +++ b/frontend/app/(main)/admin/push/components/events-tab.tsx @@ -43,6 +43,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Select, SelectContent, @@ -71,6 +81,9 @@ import { PushService } from '@/lib/services/push'; export function EventsTab() { const queryClient = useQueryClient(); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ @@ -146,6 +159,7 @@ export function EventsTab() { const deleteEventMutation = useMutation({ mutationFn: (id: number) => PushService.deleteEvent(id), onSuccess: () => { + setDeleteTarget(null); toast.success('配置删除成功'); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, @@ -452,11 +466,7 @@ export function EventsTab() { size='icon' className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10' disabled={deleteEventMutation.isPending} - onClick={() => { - if (confirm('确定要删除该通知事件配置吗?')) { - deleteEventMutation.mutate(event.id); - } - }} + onClick={() => setDeleteTarget(event)} > @@ -908,6 +918,33 @@ export function EventsTab() { + + !open && setDeleteTarget(null)} + > + + + 确认删除通知事件 + + 确定要删除该通知事件配置吗?删除后无法恢复。 + + + + + 取消 + + + deleteTarget && deleteEventMutation.mutate(deleteTarget.id) + } + > + {deleteEventMutation.isPending ? '删除中...' : '确认删除'} + + + + ); } diff --git a/frontend/app/(main)/admin/push/components/settings-tab.tsx b/frontend/app/(main)/admin/push/components/settings-tab.tsx index 9ac4c61e..a2e5ac51 100644 --- a/frontend/app/(main)/admin/push/components/settings-tab.tsx +++ b/frontend/app/(main)/admin/push/components/settings-tab.tsx @@ -31,6 +31,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Select, SelectContent, @@ -52,6 +62,9 @@ import { PushService } from '@/lib/services/push'; export function SettingsTab() { const queryClient = useQueryClient(); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ @@ -94,6 +107,7 @@ export function SettingsTab() { const deleteChannelMutation = useMutation({ mutationFn: (id: number) => PushService.deleteChannel(id), onSuccess: () => { + setDeleteTarget(null); toast.success('通道删除成功'); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); }, @@ -417,11 +431,7 @@ export function SettingsTab() { variant='ghost' size='sm' disabled={deleteChannelMutation.isPending} - onClick={() => { - if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) { - deleteChannelMutation.mutate(ch.id); - } - }} + onClick={() => setDeleteTarget(ch)} className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10' > @@ -761,6 +771,33 @@ export function SettingsTab() { + + !open && setDeleteTarget(null)} + > + + + 确认删除通道 + + 确定要删除通道「{deleteTarget?.name}」吗?删除后无法恢复。 + + + + + 取消 + + + deleteTarget && deleteChannelMutation.mutate(deleteTarget.id) + } + > + {deleteChannelMutation.isPending ? '删除中...' : '确认删除'} + + + + ); } diff --git a/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.test.tsx b/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.test.tsx index 042251af..86a2a522 100644 --- a/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.test.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.test.tsx @@ -1,12 +1,13 @@ -import { render } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { afterEach, expect, it, vi } from 'vitest'; import { UnsavedChanges } from './unsaved-changes'; afterEach(() => vi.restoreAllMocks()); -it('blocks same-origin application links when dirty and confirmation is declined', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); +it('blocks same-origin application links when dirty and confirmation is declined', async () => { + const user = userEvent.setup(); const { container } = render( <> @@ -18,13 +19,18 @@ it('blocks same-origin application links when dirty and confirmation is declined cancelable: true, button: 0, }); - container.querySelector('a')!.dispatchEvent(event); - expect(window.confirm).toHaveBeenCalledOnce(); + act(() => { + container.querySelector('a')!.dispatchEvent(event); + }); expect(event.defaultPrevented).toBe(true); + expect( + await screen.findByText('存在未保存的更改,确定离开吗?'), + ).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '取消' })); + expect(screen.queryByText('存在未保存的更改,确定离开吗?')).toBeNull(); }); it('does not block application links without changes', () => { - const confirm = vi.spyOn(window, 'confirm'); const { getByRole } = render( <> @@ -38,36 +44,49 @@ it('does not block application links without changes', () => { }); event.preventDefault(); getByRole('link').dispatchEvent(event); - expect(confirm).not.toHaveBeenCalled(); + expect(screen.queryByText('存在未保存的更改,确定离开吗?')).toBeNull(); }); -it('restores declined Back and Forward transitions by indexed delta', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); +it('restores declined Back and Forward transitions by indexed delta', async () => { + const user = userEvent.setup(); const go = vi.spyOn(history, 'go').mockImplementation(() => undefined); history.replaceState({ __wafEditorIndex: 4 }, ''); render(); - window.dispatchEvent( - new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }), - ); + + act(() => { + window.dispatchEvent( + new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }), + ); + }); expect(go).toHaveBeenLastCalledWith(1); - window.dispatchEvent( - new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }), - ); - window.dispatchEvent( - new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }), - ); + expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '取消' })); + + act(() => { + window.dispatchEvent( + new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }), + ); + }); + act(() => { + window.dispatchEvent( + new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }), + ); + }); expect(go).toHaveBeenLastCalledWith(-2); }); -it('prompts and restores the current URL for an unknown unindexed history entry', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); +it('prompts and restores the current URL for an unknown unindexed history entry', async () => { history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9'); const push = vi.spyOn(history, 'pushState'); render(); - window.dispatchEvent( - new PopStateEvent('popstate', { state: { legacy: true } }), - ); - expect(window.confirm).toHaveBeenCalledOnce(); + + act(() => { + window.dispatchEvent( + new PopStateEvent('popstate', { state: { legacy: true } }), + ); + }); + + expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy(); expect(push).toHaveBeenCalledWith( expect.objectContaining({ __wafEditorIndex: 4 }), '', diff --git a/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx b/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx index 11ad9f5e..3226f893 100644 --- a/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx @@ -1,13 +1,39 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + import { getHistoryTransition } from './editor-behavior'; const historyIndexKey = '__wafEditorIndex'; +type PendingLeave = + | { kind: 'href'; href: string } + | { kind: 'history'; restoreDelta: number }; + export function UnsavedChanges({ dirty }: { dirty: boolean }) { + const [pendingLeave, setPendingLeave] = useState(null); + const dirtyRef = useRef(dirty); + const allowLeaveRef = useRef(false); + + dirtyRef.current = dirty; + useEffect(() => { - if (!dirty) return; + if (!dirty) { + setPendingLeave(null); + return; + } + const initialState = history.state && typeof history.state === 'object' ? history.state : {}; let currentIndex = Number.isInteger(initialState[historyIndexKey]) @@ -15,41 +41,51 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) { : 0; const currentUrl = window.location.pathname + window.location.search + window.location.hash; + history.replaceState( { ...initialState, [historyIndexKey]: currentIndex }, '', ); + const originalPushState = history.pushState.bind(history); const originalReplaceState = history.replaceState.bind(history); + history.pushState = (data, unused, url) => { currentIndex++; originalPushState( - { ...data, [historyIndexKey]: currentIndex }, + { ...(data as object), [historyIndexKey]: currentIndex }, unused, url, ); }; history.replaceState = (data, unused, url) => originalReplaceState( - { ...data, [historyIndexKey]: currentIndex }, + { ...(data as object), [historyIndexKey]: currentIndex }, unused, url, ); + let restoring = false; + const handler = (event: BeforeUnloadEvent) => { - if (dirty) event.preventDefault(); + if (dirtyRef.current && !allowLeaveRef.current) { + event.preventDefault(); + } }; + const clickHandler = (event: MouseEvent) => { if ( - !dirty || + !dirtyRef.current || + allowLeaveRef.current || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey - ) + ) { return; + } const link = (event.target as Element | null)?.closest( 'a[href]', ) as HTMLAnchorElement | null; @@ -58,39 +94,55 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) { link.target === '_blank' || new URL(link.href, window.location.href).origin !== window.location.origin - ) + ) { return; - if (!window.confirm('存在未保存的更改,确定离开吗?')) - event.preventDefault(); + } + event.preventDefault(); + setPendingLeave({ kind: 'href', href: link.href }); }; + const popstateHandler = (event: PopStateEvent) => { const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]); const targetIndex = hasTargetIndex ? (event.state[historyIndexKey] as number) : currentIndex; + if (restoring) { restoring = false; currentIndex = targetIndex; return; } - if (window.confirm('存在未保存的更改,确定离开吗?')) { + + if (allowLeaveRef.current || !dirtyRef.current) { currentIndex = targetIndex; return; } + + // Browser already navigated; restore immediately and ask for confirmation. if (!hasTargetIndex) { + const destination = + window.location.pathname + + window.location.search + + window.location.hash; originalPushState( { ...initialState, [historyIndexKey]: currentIndex }, '', currentUrl, ); + setPendingLeave({ kind: 'href', href: destination }); return; } + + const { restoreDelta } = getHistoryTransition(currentIndex, targetIndex); restoring = true; - history.go(getHistoryTransition(currentIndex, targetIndex).restoreDelta); + history.go(restoreDelta); + setPendingLeave({ kind: 'history', restoreDelta }); }; + window.addEventListener('beforeunload', handler); document.addEventListener('click', clickHandler, true); window.addEventListener('popstate', popstateHandler); + return () => { history.pushState = originalPushState; history.replaceState = originalReplaceState; @@ -99,5 +151,47 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) { window.removeEventListener('popstate', popstateHandler); }; }, [dirty]); - return null; + + const handleConfirmLeave = () => { + if (!pendingLeave) return; + + allowLeaveRef.current = true; + const leave = pendingLeave; + setPendingLeave(null); + + if (leave.kind === 'href') { + window.location.assign(leave.href); + return; + } + + // Re-apply the history navigation the user originally attempted. + const delta = -leave.restoreDelta; + if (delta !== 0) { + history.go(delta); + } + }; + + return ( + { + if (!open) setPendingLeave(null); + }} + > + + + 未保存的更改 + + 存在未保存的更改,确定离开吗? + + + + 取消 + + 确定离开 + + + + + ); } diff --git a/frontend/app/(main)/waf/rules/editor/page.tsx b/frontend/app/(main)/waf/rules/editor/page.tsx index b1e08cf2..3b008a8e 100644 --- a/frontend/app/(main)/waf/rules/editor/page.tsx +++ b/frontend/app/(main)/waf/rules/editor/page.tsx @@ -7,6 +7,16 @@ import { ArrowLeft, GitBranch, Save } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -165,9 +175,13 @@ function EditorContent() { if (!graph) return; changeGraph(layoutRuleGraph(graph)); }, [changeGraph, graph]); + const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const leave = () => { - if (!dirty || window.confirm('存在未保存的更改,确定离开吗?')) + if (!dirty) { router.push('/waf'); + return; + } + setLeaveConfirmOpen(true); }; if (!Number.isFinite(id) || id <= 0) @@ -280,6 +294,23 @@ function EditorContent() { /> )} + + + + + 未保存的更改 + + 存在未保存的更改,确定离开吗? + + + + 取消 + router.push('/waf')}> + 确定离开 + + + + ); } diff --git a/frontend/components/common/settings/access-token.tsx b/frontend/components/common/settings/access-token.tsx index 6e26b2c5..d830e34a 100644 --- a/frontend/components/common/settings/access-token.tsx +++ b/frontend/components/common/settings/access-token.tsx @@ -37,6 +37,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Breadcrumb, BreadcrumbItem, @@ -50,6 +60,8 @@ import { UserService } from '@/lib/services/user'; import { useAuth } from '@/components/providers/auth-provider'; import { toast } from 'sonner'; +type ConfirmTarget = { id: number; name: string }; + export function AccessTokenMain() { const { user } = useAuth(); const queryClient = useQueryClient(); @@ -60,6 +72,12 @@ export function AccessTokenMain() { const [copiedId, setCopiedId] = React.useState(null); const [newCreatedToken, setNewCreatedToken] = React.useState(null); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); + const [rotateTarget, setRotateTarget] = React.useState( + null, + ); // 获取 Token 列表 const accessTokensQuery = useQuery({ @@ -91,6 +109,7 @@ export function AccessTokenMain() { const deleteTokenMutation = useMutation({ mutationFn: (id: number) => UserService.deleteAccessToken(id), onSuccess: () => { + setDeleteTarget(null); void queryClient.invalidateQueries({ queryKey: ['user', 'access-tokens'], }); @@ -105,6 +124,7 @@ export function AccessTokenMain() { const rotateTokenMutation = useMutation({ mutationFn: (id: number) => UserService.rotateAccessToken(id), onSuccess: (data) => { + setRotateTarget(null); setNewCreatedToken(data); setViewDialogOpen(true); void queryClient.invalidateQueries({ @@ -129,26 +149,6 @@ export function AccessTokenMain() { }); }; - const handleDeleteToken = (id: number, name: string) => { - if ( - window.confirm( - `确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`, - ) - ) { - deleteTokenMutation.mutate(id); - } - }; - - const handleRotateToken = (id: number, name: string) => { - if ( - window.confirm( - `确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`, - ) - ) { - rotateTokenMutation.mutate(id); - } - }; - const handleCopyText = async (text: string, id: number) => { try { await navigator.clipboard.writeText(text); @@ -289,22 +289,8 @@ export function AccessTokenMain() { size='sm' className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5' onClick={() => - handleCopyText(token.masked_token, token.id) + setRotateTarget({ id: token.id, name: token.name }) } - > - {copiedId === token.id ? ( - - ) : ( - - )} - 复制 - - @@ -702,6 +706,35 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { await authSourcesQuery.refetch(); }} /> + + !open && setDeleteTarget(null)} + > + + + 确认删除认证源 + + 确定删除认证源「 + {deleteTarget?.display_name || deleteTarget?.name} + 」吗?删除后无法恢复。 + + + + + 取消 + + + deleteTarget && deleteSourceMutation.mutate(deleteTarget.id) + } + > + {deleteSourceMutation.isPending ? '删除中...' : '确认删除'} + + + + ); } diff --git a/frontend/components/common/settings/templates.tsx b/frontend/components/common/settings/templates.tsx index bb91ef91..62ec991b 100644 --- a/frontend/components/common/settings/templates.tsx +++ b/frontend/components/common/settings/templates.tsx @@ -23,6 +23,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import services from '@/lib/services'; import type { Template } from '@/lib/services/admin/types'; import { toast } from 'sonner'; @@ -33,6 +43,7 @@ export function TemplatesManager() { const [selectedTemplate, setSelectedTemplate] = useState