feat(cloudflare): add DNS pointing integration

This commit is contained in:
ryan
2026-08-04 13:30:21 +08:00
parent ea7658815a
commit 4eced2b721
22 changed files with 1834 additions and 376 deletions
@@ -1,8 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
@@ -12,17 +20,43 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
type DomainZoneGroup = {
zoneId: number;
zoneDomain: string;
domains: CloudflareAvailableDomain[];
};
function groupDomainsByZone(
domains: CloudflareAvailableDomain[],
): DomainZoneGroup[] {
const map = new Map<number, DomainZoneGroup>();
for (const domain of domains) {
const existing = map.get(domain.zone_id);
if (existing) {
existing.domains.push(domain);
continue;
}
map.set(domain.zone_id, {
zoneId: domain.zone_id,
zoneDomain: domain.zone_domain || `Zone #${domain.zone_id}`,
domains: [domain],
});
}
return [...map.values()]
.map((group) => ({
...group,
domains: [...group.domains].sort((a, b) =>
a.domain.localeCompare(b.domain),
),
}))
.sort((a, b) => a.zoneDomain.localeCompare(b.zoneDomain));
}
export function MemberAddDialog({
open,
onOpenChange,
@@ -36,62 +70,315 @@ export function MemberAddDialog({
domains: CloudflareAvailableDomain[];
defaultProxied: boolean;
pending: boolean;
onSubmit: (zoneDomainID: number, proxied: boolean) => void;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) {
const [domainID, setDomainID] = useState('');
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied);
const [collapsedZones, setCollapsedZones] = useState<Set<number>>(
() => new Set(),
);
useEffect(() => {
if (!open) return;
setDomainID('');
setKeyword('');
setSelectedIDs([]);
setProxied(defaultProxied);
setCollapsedZones(new Set());
}, [defaultProxied, open]);
const selected = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const filteredGroups = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
const list = normalized
? domains.filter((domain) => {
const zoneRoot = domain.zone_domain ?? '';
return (
domain.domain.toLowerCase().includes(normalized) ||
zoneRoot.toLowerCase().includes(normalized) ||
String(domain.id).includes(normalized)
);
})
: domains;
return groupDomainsByZone(list);
}, [domains, keyword]);
const visibleIDs = useMemo(
() => filteredGroups.flatMap((group) => group.domains.map((d) => d.id)),
[filteredGroups],
);
const allVisibleSelected =
visibleIDs.length > 0 && visibleIDs.every((id) => selected.has(id));
const someVisibleSelected =
visibleIDs.some((id) => selected.has(id)) && !allVisibleSelected;
const toggleOne = (id: number) => {
setSelectedIDs((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
};
const toggleGroup = (group: DomainZoneGroup) => {
const ids = group.domains.map((d) => d.id);
const allSelected = ids.every((id) => selected.has(id));
setSelectedIDs((prev) => {
if (allSelected) {
return prev.filter((id) => !ids.includes(id));
}
const next = new Set(prev);
for (const id of ids) next.add(id);
return [...next];
});
};
const toggleAllVisible = () => {
setSelectedIDs((prev) => {
if (allVisibleSelected) {
return prev.filter((id) => !visibleIDs.includes(id));
}
const next = new Set(prev);
for (const id of visibleIDs) next.add(id);
return [...next];
});
};
const toggleCollapsed = (zoneId: number) => {
setCollapsedZones((prev) => {
const next = new Set(prev);
if (next.has(zoneId)) next.delete(zoneId);
else next.add(zoneId);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>添加域名成员</DialogTitle>
<DialogDescription>
加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名
A 会拒绝同步。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-domain'>Zone 域名</FieldLabel>
<Select value={domainID} onValueChange={setDomainID}>
<SelectTrigger id='cf-domain' className='w-full'>
<SelectValue placeholder='选择可用域名' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.domain}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
id='cf-domain-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…'
className='pl-8'
disabled={pending}
/>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
已选 {selectedIDs.length}
</Badge>
<span>可见 {visibleIDs.length}</span>
</div>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])}
>
清空
</Button>
</div>
</div>
{domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。
</div>
) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
{/* Header row: select all visible */}
<label className='flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted/50'>
<Checkbox
checked={
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={toggleAllVisible}
/>
<span>按顶级域分组</span>
</label>
{filteredGroups.map((group) => {
const groupIDs = group.domains.map((d) => d.id);
const groupAllSelected = groupIDs.every((id) =>
selected.has(id),
);
const groupSomeSelected =
groupIDs.some((id) => selected.has(id)) &&
!groupAllSelected;
const open = !collapsedZones.has(group.zoneId);
return (
<Collapsible
key={group.zoneId}
open={open}
onOpenChange={() => toggleCollapsed(group.zoneId)}
>
<div
className={cn(
'rounded-md',
(groupAllSelected || groupSomeSelected) &&
'bg-muted/30',
)}
>
<div className='flex items-center gap-1 px-1 py-0.5'>
<Checkbox
checked={
groupAllSelected
? true
: groupSomeSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={() => toggleGroup(group)}
aria-label={`选择顶级域 ${group.zoneDomain}`}
className='ml-1'
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm font-medium hover:bg-muted/60'
>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
/>
<span className='truncate'>
{group.zoneDomain}
</span>
<Badge
variant='outline'
className='ml-auto shrink-0 font-normal text-[10px]'
>
{
groupIDs.filter((id) => selected.has(id))
.length
}
/{group.domains.length}
</Badge>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='ml-4 space-y-0.5 border-l border-border/70 py-0.5 pl-2'>
{group.domains.map((domain) => {
const checked = selected.has(domain.id);
const isApex =
domain.domain === group.zoneDomain;
return (
<label
key={domain.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60',
checked && 'bg-muted/40',
)}
>
<Checkbox
checked={checked}
disabled={pending}
onCheckedChange={() =>
toggleOne(domain.id)
}
/>
<span className='min-w-0 flex-1 truncate text-sm'>
{domain.domain}
</span>
{isApex ? (
<Badge
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
顶级域
</Badge>
) : null}
{checked ? (
<Check className='size-3.5 shrink-0 text-primary' />
) : null}
</label>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
)}
</div>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
disabled={pending}
onCheckedChange={setProxied}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
<Button
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button
disabled={pending || !domainID}
onClick={() => onSubmit(Number(domainID), proxied)}
disabled={pending || selectedIDs.length === 0}
onClick={() =>
onSubmit(
[...selectedIDs].sort((a, b) => a - b),
proxied,
)
}
>
添加并同步
{selectedIDs.length > 1
? `添加并同步(${selectedIDs.length})`
: '添加并同步'}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,492 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Activity,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { AdminTaskService } from '@/lib/services/admin';
import type {
ListTaskExecutionsResponse,
TaskExecution,
TaskExecutionStatus,
} from '@/lib/services/admin';
import { cloudflareQueryKey } from '@/lib/services/openflare';
/**
* Only domain-member and group sync jobs.
* Uses exact asynq task_type filters (compatible with existing admin API).
* Excludes cloudflare:sync_by_node and any unrelated scheduled system tasks.
*/
export const CLOUDFLARE_SYNC_TASK_TYPES = [
'cloudflare:sync_member',
'cloudflare:sync_group',
] as const;
const ALLOWED_TASK_TYPES = new Set<string>(CLOUDFLARE_SYNC_TASK_TYPES);
/** Per-type fetch window; merged and paginated on the client. */
const FETCH_PAGE_SIZE = 50;
const PAGE_SIZE = 10;
async function listCloudflareSyncExecutions(options: {
page: number;
status?: TaskExecutionStatus;
}): Promise<ListTaskExecutionsResponse> {
const status = options.status;
const responses = await Promise.all(
CLOUDFLARE_SYNC_TASK_TYPES.map((taskType) =>
AdminTaskService.listTaskExecutions({
task_type: taskType,
page: 1,
page_size: FETCH_PAGE_SIZE,
status,
}),
),
);
const merged = responses
.flatMap((response) => response.items)
.filter((item) => ALLOWED_TASK_TYPES.has(item.task_type))
.sort((a, b) => {
const timeA = Date.parse(a.created_at) || 0;
const timeB = Date.parse(b.created_at) || 0;
if (timeA !== timeB) return timeB - timeA;
return String(b.id).localeCompare(String(a.id), undefined, {
numeric: true,
});
});
const start = (options.page - 1) * PAGE_SIZE;
return {
items: merged.slice(start, start + PAGE_SIZE),
total: merged.length,
page: options.page,
page_size: PAGE_SIZE,
};
}
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
};
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
function formatDuration(duration: number) {
if (!duration) return '-';
if (duration < 1000) return `${duration}ms`;
return `${(duration / 1000).toFixed(2)}s`;
}
function statusVariant(status: TaskExecutionStatus) {
if (status === 'failed') return 'destructive' as const;
if (status === 'succeeded') return 'secondary' as const;
return 'outline' as const;
}
export function SyncTasksPanel() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preview, setPreview] = useState<TaskExecution | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const executionsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'sync-executions', page, status],
queryFn: () =>
listCloudflareSyncExecutions({
page,
status: status === 'all' ? undefined : status,
}),
refetchInterval: (query) => {
const items = query.state.data?.items ?? [];
const active = items.some(
(item) => item.status === 'pending' || item.status === 'running',
);
return active ? 3000 : 15000;
},
});
const detailQuery = useQuery({
queryKey: ['admin', 'task-execution', selectedId],
queryFn: () => AdminTaskService.getTaskExecution(selectedId!),
enabled: detailOpen && !!selectedId,
});
const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('同步任务已重新下发', {
description: `新任务 ID:${taskID}`,
});
void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
});
},
});
const executions = executionsQuery.data?.items ?? [];
const total = executionsQuery.data?.total ?? 0;
const loading = executionsQuery.isPending || executionsQuery.isFetching;
const selected = detailQuery.data ?? preview;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const openDetail = (execution: TaskExecution) => {
setPreview(execution);
setSelectedId(execution.id);
setDetailOpen(true);
};
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' />
同步任务
</CardTitle>
<CardDescription>
仅展示 Cloudflare
域名同步(sync_member)与分组同步(sync_group)的执行记录。
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as TaskExecutionStatus | 'all');
setPage(1);
}}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
</SelectContent>
</Select>
<Button
variant='outline'
size='sm'
onClick={() => void executionsQuery.refetch()}
disabled={loading}
>
{loading ? (
<Spinner className='size-4' />
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{executionsQuery.isError ? (
<ErrorInline
message={
executionsQuery.error instanceof Error
? executionsQuery.error.message
: '加载同步任务失败'
}
onRetry={() => void executionsQuery.refetch()}
/>
) : loading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载同步任务中...'
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
title='暂无同步任务'
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。'
/>
) : (
<div className='rounded-lg border'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[100px]'>触发</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='min-w-[180px]'>结果/错误</TableHead>
<TableHead className='w-[160px]'>创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className='cursor-pointer'
onClick={() => openDetail(execution)}
>
<TableCell>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{execution.task_name || execution.task_type}
</span>
<span className='font-mono text-[11px] text-muted-foreground'>
{execution.task_type}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
<TableCell className='font-mono text-xs text-muted-foreground'>
{formatDuration(execution.duration)}
</TableCell>
<TableCell className='max-w-[280px] truncate text-xs text-muted-foreground'>
{execution.error_message || execution.result || '-'}
</TableCell>
<TableCell className='font-mono text-[11px] text-muted-foreground'>
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'>
共 {total} 条,第 {page}/{totalPages} 页
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className='size-4' />
上一页
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
下一页
<ChevronRight className='size-4' />
</Button>
</div>
</div>
</CardContent>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'>
<SheetTitle>同步任务详情</SheetTitle>
<SheetDescription>
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
/>
) : selected ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='mt-2'>
<Badge variant={statusVariant(selected.status)}>
{STATUS_LABELS[selected.status] || selected.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>触发</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selected.triggered_by] ||
selected.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>重试</div>
<div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>任务 ID</div>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id}
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
创建时间
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.created_at)}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
结束时间
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>执行结果</div>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'}
</div>
</div>
{selected.error_message ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
错误信息
</div>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selected.error_message}
</div>
</div>
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>Payload</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
</div>
{selected.log ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
执行日志
</div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log}
</pre>
</div>
) : null}
</>
) : null}
</div>
<SheetFooter className='border-t'>
{selected?.retryable && selected.status === 'failed' ? (
<Button
variant='outline'
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(selected.id)}
>
{retryMutation.isPending ? (
<Spinner className='size-4' />
) : (
<RotateCcw data-icon='inline-start' />
)}
重试
</Button>
) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}>
关闭
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</Card>
);
}