mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
fix(frontend): replace browser confirm dialogs with AlertDialog
统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免 window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user