fix(frontend): replace browser confirm dialogs with AlertDialog

统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免
window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。
This commit is contained in:
ryan
2026-08-06 13:08:43 +08:00
parent cffa009b8c
commit 484b49d79d
9 changed files with 429 additions and 100 deletions
+1
View File
@@ -28,6 +28,7 @@ sidebar: false
### 改进 ### 改进
- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。
- Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。 - Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。
- Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。 - Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。
- Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。 - Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。
@@ -43,6 +43,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -71,6 +81,9 @@ import { PushService } from '@/lib/services/push';
export function EventsTab() { export function EventsTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
null,
);
// --- 获取所有自定义消息通道 --- // --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({ const channelsQuery = useQuery({
@@ -146,6 +159,7 @@ export function EventsTab() {
const deleteEventMutation = useMutation({ const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id), mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => { onSuccess: () => {
setDeleteTarget(null);
toast.success('配置删除成功'); toast.success('配置删除成功');
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
}, },
@@ -452,11 +466,7 @@ export function EventsTab() {
size='icon' size='icon'
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10' className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
disabled={deleteEventMutation.isPending} disabled={deleteEventMutation.isPending}
onClick={() => { onClick={() => setDeleteTarget(event)}
if (confirm('确定要删除该通知事件配置吗?')) {
deleteEventMutation.mutate(event.id);
}
}}
> >
<Trash2 className='size-3' /> <Trash2 className='size-3' />
</Button> </Button>
@@ -908,6 +918,33 @@ export function EventsTab() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除通知事件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除该通知事件配置吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteEventMutation.isPending}
onClick={() =>
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
}
>
{deleteEventMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -31,6 +31,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -52,6 +62,9 @@ import { PushService } from '@/lib/services/push';
export function SettingsTab() { export function SettingsTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
null,
);
// --- 获取所有自定义消息通道 --- // --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({ const channelsQuery = useQuery({
@@ -94,6 +107,7 @@ export function SettingsTab() {
const deleteChannelMutation = useMutation({ const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id), mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => { onSuccess: () => {
setDeleteTarget(null);
toast.success('通道删除成功'); toast.success('通道删除成功');
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
}, },
@@ -417,11 +431,7 @@ export function SettingsTab() {
variant='ghost' variant='ghost'
size='sm' size='sm'
disabled={deleteChannelMutation.isPending} disabled={deleteChannelMutation.isPending}
onClick={() => { onClick={() => setDeleteTarget(ch)}
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
deleteChannelMutation.mutate(ch.id);
}
}}
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10' className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
> >
<Trash2 className='size-2.5 mr-1' /> <Trash2 className='size-2.5 mr-1' />
@@ -761,6 +771,33 @@ export function SettingsTab() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除通道</AlertDialogTitle>
<AlertDialogDescription>
确定要删除通道「{deleteTarget?.name}」吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteChannelMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteChannelMutation.isPending}
onClick={() =>
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
}
>
{deleteChannelMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -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 { afterEach, expect, it, vi } from 'vitest';
import { UnsavedChanges } from './unsaved-changes'; import { UnsavedChanges } from './unsaved-changes';
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('blocks same-origin application links when dirty and confirmation is declined', () => { it('blocks same-origin application links when dirty and confirmation is declined', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false); const user = userEvent.setup();
const { container } = render( const { container } = render(
<> <>
<UnsavedChanges dirty /> <UnsavedChanges dirty />
@@ -18,13 +19,18 @@ it('blocks same-origin application links when dirty and confirmation is declined
cancelable: true, cancelable: true,
button: 0, button: 0,
}); });
container.querySelector('a')!.dispatchEvent(event); act(() => {
expect(window.confirm).toHaveBeenCalledOnce(); container.querySelector('a')!.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true); 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', () => { it('does not block application links without changes', () => {
const confirm = vi.spyOn(window, 'confirm');
const { getByRole } = render( const { getByRole } = render(
<> <>
<UnsavedChanges dirty={false} /> <UnsavedChanges dirty={false} />
@@ -38,36 +44,49 @@ it('does not block application links without changes', () => {
}); });
event.preventDefault(); event.preventDefault();
getByRole('link').dispatchEvent(event); getByRole('link').dispatchEvent(event);
expect(confirm).not.toHaveBeenCalled(); expect(screen.queryByText('存在未保存的更改,确定离开吗?')).toBeNull();
}); });
it('restores declined Back and Forward transitions by indexed delta', () => { it('restores declined Back and Forward transitions by indexed delta', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false); const user = userEvent.setup();
const go = vi.spyOn(history, 'go').mockImplementation(() => undefined); const go = vi.spyOn(history, 'go').mockImplementation(() => undefined);
history.replaceState({ __wafEditorIndex: 4 }, ''); history.replaceState({ __wafEditorIndex: 4 }, '');
render(<UnsavedChanges dirty />); render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }), act(() => {
); window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }),
);
});
expect(go).toHaveBeenLastCalledWith(1); expect(go).toHaveBeenLastCalledWith(1);
window.dispatchEvent( expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy();
new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }), await user.click(screen.getByRole('button', { name: '取消' }));
);
window.dispatchEvent( act(() => {
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }), window.dispatchEvent(
); new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }),
);
});
act(() => {
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }),
);
});
expect(go).toHaveBeenLastCalledWith(-2); expect(go).toHaveBeenLastCalledWith(-2);
}); });
it('prompts and restores the current URL for an unknown unindexed history entry', () => { it('prompts and restores the current URL for an unknown unindexed history entry', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9'); history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9');
const push = vi.spyOn(history, 'pushState'); const push = vi.spyOn(history, 'pushState');
render(<UnsavedChanges dirty />); render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { legacy: true } }), act(() => {
); window.dispatchEvent(
expect(window.confirm).toHaveBeenCalledOnce(); new PopStateEvent('popstate', { state: { legacy: true } }),
);
});
expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy();
expect(push).toHaveBeenCalledWith( expect(push).toHaveBeenCalledWith(
expect.objectContaining({ __wafEditorIndex: 4 }), expect.objectContaining({ __wafEditorIndex: 4 }),
'', '',
@@ -1,13 +1,39 @@
'use client'; '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'; import { getHistoryTransition } from './editor-behavior';
const historyIndexKey = '__wafEditorIndex'; const historyIndexKey = '__wafEditorIndex';
type PendingLeave =
| { kind: 'href'; href: string }
| { kind: 'history'; restoreDelta: number };
export function UnsavedChanges({ dirty }: { dirty: boolean }) { export function UnsavedChanges({ dirty }: { dirty: boolean }) {
const [pendingLeave, setPendingLeave] = useState<PendingLeave | null>(null);
const dirtyRef = useRef(dirty);
const allowLeaveRef = useRef(false);
dirtyRef.current = dirty;
useEffect(() => { useEffect(() => {
if (!dirty) return; if (!dirty) {
setPendingLeave(null);
return;
}
const initialState = const initialState =
history.state && typeof history.state === 'object' ? history.state : {}; history.state && typeof history.state === 'object' ? history.state : {};
let currentIndex = Number.isInteger(initialState[historyIndexKey]) let currentIndex = Number.isInteger(initialState[historyIndexKey])
@@ -15,41 +41,51 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
: 0; : 0;
const currentUrl = const currentUrl =
window.location.pathname + window.location.search + window.location.hash; window.location.pathname + window.location.search + window.location.hash;
history.replaceState( history.replaceState(
{ ...initialState, [historyIndexKey]: currentIndex }, { ...initialState, [historyIndexKey]: currentIndex },
'', '',
); );
const originalPushState = history.pushState.bind(history); const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history); const originalReplaceState = history.replaceState.bind(history);
history.pushState = (data, unused, url) => { history.pushState = (data, unused, url) => {
currentIndex++; currentIndex++;
originalPushState( originalPushState(
{ ...data, [historyIndexKey]: currentIndex }, { ...(data as object), [historyIndexKey]: currentIndex },
unused, unused,
url, url,
); );
}; };
history.replaceState = (data, unused, url) => history.replaceState = (data, unused, url) =>
originalReplaceState( originalReplaceState(
{ ...data, [historyIndexKey]: currentIndex }, { ...(data as object), [historyIndexKey]: currentIndex },
unused, unused,
url, url,
); );
let restoring = false; let restoring = false;
const handler = (event: BeforeUnloadEvent) => { const handler = (event: BeforeUnloadEvent) => {
if (dirty) event.preventDefault(); if (dirtyRef.current && !allowLeaveRef.current) {
event.preventDefault();
}
}; };
const clickHandler = (event: MouseEvent) => { const clickHandler = (event: MouseEvent) => {
if ( if (
!dirty || !dirtyRef.current ||
allowLeaveRef.current ||
event.defaultPrevented || event.defaultPrevented ||
event.button !== 0 || event.button !== 0 ||
event.metaKey || event.metaKey ||
event.ctrlKey || event.ctrlKey ||
event.shiftKey || event.shiftKey ||
event.altKey event.altKey
) ) {
return; return;
}
const link = (event.target as Element | null)?.closest( const link = (event.target as Element | null)?.closest(
'a[href]', 'a[href]',
) as HTMLAnchorElement | null; ) as HTMLAnchorElement | null;
@@ -58,39 +94,55 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
link.target === '_blank' || link.target === '_blank' ||
new URL(link.href, window.location.href).origin !== new URL(link.href, window.location.href).origin !==
window.location.origin window.location.origin
) ) {
return; return;
if (!window.confirm('存在未保存的更改,确定离开吗?')) }
event.preventDefault(); event.preventDefault();
setPendingLeave({ kind: 'href', href: link.href });
}; };
const popstateHandler = (event: PopStateEvent) => { const popstateHandler = (event: PopStateEvent) => {
const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]); const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]);
const targetIndex = hasTargetIndex const targetIndex = hasTargetIndex
? (event.state[historyIndexKey] as number) ? (event.state[historyIndexKey] as number)
: currentIndex; : currentIndex;
if (restoring) { if (restoring) {
restoring = false; restoring = false;
currentIndex = targetIndex; currentIndex = targetIndex;
return; return;
} }
if (window.confirm('存在未保存的更改,确定离开吗?')) {
if (allowLeaveRef.current || !dirtyRef.current) {
currentIndex = targetIndex; currentIndex = targetIndex;
return; return;
} }
// Browser already navigated; restore immediately and ask for confirmation.
if (!hasTargetIndex) { if (!hasTargetIndex) {
const destination =
window.location.pathname +
window.location.search +
window.location.hash;
originalPushState( originalPushState(
{ ...initialState, [historyIndexKey]: currentIndex }, { ...initialState, [historyIndexKey]: currentIndex },
'', '',
currentUrl, currentUrl,
); );
setPendingLeave({ kind: 'href', href: destination });
return; return;
} }
const { restoreDelta } = getHistoryTransition(currentIndex, targetIndex);
restoring = true; restoring = true;
history.go(getHistoryTransition(currentIndex, targetIndex).restoreDelta); history.go(restoreDelta);
setPendingLeave({ kind: 'history', restoreDelta });
}; };
window.addEventListener('beforeunload', handler); window.addEventListener('beforeunload', handler);
document.addEventListener('click', clickHandler, true); document.addEventListener('click', clickHandler, true);
window.addEventListener('popstate', popstateHandler); window.addEventListener('popstate', popstateHandler);
return () => { return () => {
history.pushState = originalPushState; history.pushState = originalPushState;
history.replaceState = originalReplaceState; history.replaceState = originalReplaceState;
@@ -99,5 +151,47 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
window.removeEventListener('popstate', popstateHandler); window.removeEventListener('popstate', popstateHandler);
}; };
}, [dirty]); }, [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 (
<AlertDialog
open={Boolean(pendingLeave)}
onOpenChange={(open) => {
if (!open) setPendingLeave(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>未保存的更改</AlertDialogTitle>
<AlertDialogDescription>
存在未保存的更改,确定离开吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>
确定离开
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
} }
+32 -1
View File
@@ -7,6 +7,16 @@ import { ArrowLeft, GitBranch, Save } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner'; 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -165,9 +175,13 @@ function EditorContent() {
if (!graph) return; if (!graph) return;
changeGraph(layoutRuleGraph(graph)); changeGraph(layoutRuleGraph(graph));
}, [changeGraph, graph]); }, [changeGraph, graph]);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const leave = () => { const leave = () => {
if (!dirty || window.confirm('存在未保存的更改,确定离开吗?')) if (!dirty) {
router.push('/waf'); router.push('/waf');
return;
}
setLeaveConfirmOpen(true);
}; };
if (!Number.isFinite(id) || id <= 0) if (!Number.isFinite(id) || id <= 0)
@@ -280,6 +294,23 @@ function EditorContent() {
/> />
)} )}
</div> </div>
<AlertDialog open={leaveConfirmOpen} onOpenChange={setLeaveConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>未保存的更改</AlertDialogTitle>
<AlertDialogDescription>
存在未保存的更改,确定离开吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => router.push('/waf')}>
确定离开
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -37,6 +37,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -50,6 +60,8 @@ import { UserService } from '@/lib/services/user';
import { useAuth } from '@/components/providers/auth-provider'; import { useAuth } from '@/components/providers/auth-provider';
import { toast } from 'sonner'; import { toast } from 'sonner';
type ConfirmTarget = { id: number; name: string };
export function AccessTokenMain() { export function AccessTokenMain() {
const { user } = useAuth(); const { user } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -60,6 +72,12 @@ export function AccessTokenMain() {
const [copiedId, setCopiedId] = React.useState<number | null>(null); const [copiedId, setCopiedId] = React.useState<number | null>(null);
const [newCreatedToken, setNewCreatedToken] = const [newCreatedToken, setNewCreatedToken] =
React.useState<CreateTokenResponse | null>(null); React.useState<CreateTokenResponse | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ConfirmTarget | null>(
null,
);
const [rotateTarget, setRotateTarget] = React.useState<ConfirmTarget | null>(
null,
);
// 获取 Token 列表 // 获取 Token 列表
const accessTokensQuery = useQuery({ const accessTokensQuery = useQuery({
@@ -91,6 +109,7 @@ export function AccessTokenMain() {
const deleteTokenMutation = useMutation({ const deleteTokenMutation = useMutation({
mutationFn: (id: number) => UserService.deleteAccessToken(id), mutationFn: (id: number) => UserService.deleteAccessToken(id),
onSuccess: () => { onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'], queryKey: ['user', 'access-tokens'],
}); });
@@ -105,6 +124,7 @@ export function AccessTokenMain() {
const rotateTokenMutation = useMutation({ const rotateTokenMutation = useMutation({
mutationFn: (id: number) => UserService.rotateAccessToken(id), mutationFn: (id: number) => UserService.rotateAccessToken(id),
onSuccess: (data) => { onSuccess: (data) => {
setRotateTarget(null);
setNewCreatedToken(data); setNewCreatedToken(data);
setViewDialogOpen(true); setViewDialogOpen(true);
void queryClient.invalidateQueries({ 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) => { const handleCopyText = async (text: string, id: number) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -289,22 +289,8 @@ export function AccessTokenMain() {
size='sm' size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5' className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5'
onClick={() => onClick={() =>
handleCopyText(token.masked_token, token.id) setRotateTarget({ id: token.id, name: token.name })
} }
>
{copiedId === token.id ? (
<Check className='size-3.5 mr-1 text-emerald-500' />
) : (
<Copy className='size-3.5 mr-1' />
)}
复制
</Button>
<Button
type='button'
variant='outline'
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={() => handleRotateToken(token.id, token.name)}
disabled={rotateTokenMutation.isPending} disabled={rotateTokenMutation.isPending}
> >
<RefreshCw <RefreshCw
@@ -317,7 +303,9 @@ export function AccessTokenMain() {
variant='outline' variant='outline'
size='sm' size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5' className='text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5'
onClick={() => handleDeleteToken(token.id, token.name)} onClick={() =>
setDeleteTarget({ id: token.id, name: token.name })
}
disabled={deleteTokenMutation.isPending} disabled={deleteTokenMutation.isPending}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
@@ -491,6 +479,62 @@ export function AccessTokenMain() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认撤销访问令牌</AlertDialogTitle>
<AlertDialogDescription>
确定要删除并撤销令牌「{deleteTarget?.name}
」吗?删除后此令牌将立即失效且不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTokenMutation.isPending}
onClick={() =>
deleteTarget && deleteTokenMutation.mutate(deleteTarget.id)
}
>
{deleteTokenMutation.isPending ? '撤销中...' : '确认撤销'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(rotateTarget)}
onOpenChange={(open) => !open && setRotateTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认轮换访问令牌</AlertDialogTitle>
<AlertDialogDescription>
确定要轮换令牌「{rotateTarget?.name}
」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rotateTokenMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={rotateTokenMutation.isPending}
onClick={() =>
rotateTarget && rotateTokenMutation.mutate(rotateTarget.id)
}
>
{rotateTokenMutation.isPending ? '轮换中...' : '确认轮换'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div> </motion.div>
); );
} }
@@ -40,6 +40,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AuthSourceModal } from '@/components/common/settings/auth-source-modal'; import { AuthSourceModal } from '@/components/common/settings/auth-source-modal';
import services from '@/lib/services'; import services from '@/lib/services';
import type { AuthSource, SystemConfig } from '@/lib/services/admin'; import type { AuthSource, SystemConfig } from '@/lib/services/admin';
@@ -94,6 +104,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false); const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null); const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AuthSource | null>(null);
const [capCount, setCapCount] = useState(''); const [capCount, setCapCount] = useState('');
const [capDifficulty, setCapDifficulty] = useState(''); const [capDifficulty, setCapDifficulty] = useState('');
@@ -232,6 +243,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await services.adminAuthSource.deleteAuthSource(sourceId); await services.adminAuthSource.deleteAuthSource(sourceId);
}, },
onSuccess: async () => { onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] }); await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'], queryKey: ['auth', 'public-sources'],
@@ -486,15 +498,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
size='icon' size='icon'
className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors' className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors'
disabled={deleteSourceMutation.isPending} disabled={deleteSourceMutation.isPending}
onClick={() => { onClick={() => setDeleteTarget(source)}
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
)
) {
deleteSourceMutation.mutate(source.id);
}
}}
> >
<Trash2 className='size-4' /> <Trash2 className='size-4' />
</Button> </Button>
@@ -702,6 +706,35 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await authSourcesQuery.refetch(); await authSourcesQuery.refetch();
}} }}
/> />
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除认证源</AlertDialogTitle>
<AlertDialogDescription>
确定删除认证源「
{deleteTarget?.display_name || deleteTarget?.name}
」吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSourceMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteSourceMutation.isPending}
onClick={() =>
deleteTarget && deleteSourceMutation.mutate(deleteTarget.id)
}
>
{deleteSourceMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -23,6 +23,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import services from '@/lib/services'; import services from '@/lib/services';
import type { Template } from '@/lib/services/admin/types'; import type { Template } from '@/lib/services/admin/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -33,6 +43,7 @@ export function TemplatesManager() {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null, null,
); );
const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
// Form states // Form states
const [key, setKey] = useState(''); const [key, setKey] = useState('');
@@ -93,6 +104,7 @@ export function TemplatesManager() {
await services.adminTemplate.deleteTemplate(key); await services.adminTemplate.deleteTemplate(key);
}, },
onSuccess: async () => { onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] }); await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已删除'); toast.success('通知模板已删除');
}, },
@@ -223,13 +235,7 @@ export function TemplatesManager() {
disabled={ disabled={
tmpl.is_system || deleteTemplateMutation.isPending tmpl.is_system || deleteTemplateMutation.isPending
} }
onClick={() => { onClick={() => setDeleteTarget(tmpl)}
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
}}
> >
<Trash2 className='size-4' /> <Trash2 className='size-4' />
</Button> </Button>
@@ -398,6 +404,33 @@ export function TemplatesManager() {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除模板</AlertDialogTitle>
<AlertDialogDescription>
确定删除模板「{deleteTarget?.name}」吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTemplateMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTemplateMutation.isPending}
onClick={() =>
deleteTarget && deleteTemplateMutation.mutate(deleteTarget.key)
}
>
{deleteTemplateMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }