This commit is contained in:
ryan
2026-08-04 13:50:10 +08:00
parent 4eced2b721
commit cffa009b8c
7 changed files with 378 additions and 341 deletions
@@ -0,0 +1,334 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Cloud,
Loader2,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
NodeService,
type CloudflareGroupPayload,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../../websites/components/website-utils';
import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
export function CloudflareGroupDetailPageClient() {
const params = useParams<{ id: string }>();
const groupID = Number(params.id);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const detailQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
enabled: Number.isInteger(groupID) && groupID > 0,
refetchInterval: 5000,
});
const domainsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'domains', 'available'],
queryFn: () => CloudflareService.listAvailableDomains(),
});
const nodesQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
const updateGroupMutation = useMutation({
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.updateGroup(groupID, payload),
onSuccess: async () => {
toast.success('分组配置已更新');
setEditOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const addMutation = useMutation({
mutationFn: async ({
domainIDs,
proxied,
}: {
domainIDs: number[];
proxied: boolean;
}) => {
const results = await Promise.allSettled(
domainIDs.map((domainID) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
),
);
const failed = results.filter((r) => r.status === 'rejected');
const succeeded = results.length - failed.length;
return { succeeded, failed: failed.length, total: results.length };
},
onSuccess: async ({ succeeded, failed, total }) => {
if (failed === 0) {
toast.success(
total === 1
? '域名已加入并排队同步'
: `已添加 ${succeeded} 个域名并排队同步`,
);
} else if (succeeded === 0) {
toast.error(`添加失败:${failed} 个域名未能加入`);
} else {
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`);
}
setAddOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const proxiedMutation = useMutation({
mutationFn: ({
memberID,
proxied,
}: {
memberID: number;
proxied: boolean;
}) => CloudflareService.updateMember(groupID, memberID, proxied),
onSuccess: async () => {
toast.success('橙云设置已更新并排队同步');
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const syncMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.syncMember(groupID, memberID),
onSuccess: () => toast.success('成员同步任务已入队'),
onError: (error) => toast.error(getErrorMessage(error)),
});
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => {
toast.success('成员及远端 A 记录已删除');
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
if (detailQuery.isLoading)
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
</div>
);
if (detailQuery.isError || !detailQuery.data)
return (
<div className='w-full py-6 px-1'>
<ErrorInline
message={getErrorMessage(detailQuery.error)}
onRetry={() => void detailQuery.refetch()}
/>
</div>
);
const { group, members } = detailQuery.data;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{group.name}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void detailQuery.refetch()}
disabled={detailQuery.isFetching}
>
{detailQuery.isFetching ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setEditOpen(true)}
>
<Settings data-icon='inline-start' />
编辑
</Button>
<Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' />
添加域名
</Button>
</div>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>当前指向</CardTitle>
<CardDescription>
生效节点 {group.active_node.name} · {group.active_node.ip}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '同步已启用' : '同步已停用'}
</Badge>
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
<Badge variant='outline'>
备用节点 {group.backup_node?.name ?? '未设置'}
</Badge>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>域名成员</CardTitle>
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
</CardHeader>
<CardContent>
{members.length === 0 ? (
<p className='py-8 text-center text-sm text-muted-foreground'>
暂无域名成员。
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>域名</TableHead>
<TableHead>期望 IP</TableHead>
<TableHead>状态</TableHead>
<TableHead>橙云</TableHead>
<TableHead className='text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className='font-medium'>{member.domain}</div>
{member.last_error ? (
<p className='max-w-md text-xs text-destructive'>
{member.last_error}
</p>
) : null}
</TableCell>
<TableCell>{member.desired_ip || '待同步'}</TableCell>
<TableCell>
<Badge
variant={
member.sync_status === 'ok'
? 'default'
: member.sync_status === 'error'
? 'destructive'
: 'secondary'
}
>
{member.sync_status}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={member.proxied}
onCheckedChange={(proxied) =>
proxiedMutation.mutate({
memberID: member.id,
proxied,
})
}
/>
</TableCell>
<TableCell>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
同步
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
移出
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<GroupDialog
open={editOpen}
onOpenChange={setEditOpen}
group={group}
nodes={nodesQuery.data ?? []}
pending={updateGroupMutation.isPending}
onSubmit={(payload) => updateGroupMutation.mutate(payload)}
/>
<MemberAddDialog
open={addOpen}
onOpenChange={setAddOpen}
domains={domainsQuery.data ?? []}
defaultProxied={group.default_proxied}
pending={addMutation.isPending}
onSubmit={(domainIDs, proxied) =>
addMutation.mutate({ domainIDs, proxied })
}
/>
</div>
);
}
@@ -1,335 +1,28 @@
'use client';
import { Suspense } from 'react';
import { Cloud } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Cloud,
Loader2,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
NodeService,
type CloudflareGroupPayload,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../../websites/components/website-utils';
import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
export default function CloudflareGroupDetailPage() {
const params = useParams<{ id: string }>();
const groupID = Number(params.id);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const detailQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
enabled: Number.isInteger(groupID) && groupID > 0,
refetchInterval: 5000,
});
const domainsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'domains', 'available'],
queryFn: () => CloudflareService.listAvailableDomains(),
});
const nodesQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
import { CloudflareGroupDetailPageClient } from './page-client';
const updateGroupMutation = useMutation({
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.updateGroup(groupID, payload),
onSuccess: async () => {
toast.success('分组配置已更新');
setEditOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const addMutation = useMutation({
mutationFn: async ({
domainIDs,
proxied,
}: {
domainIDs: number[];
proxied: boolean;
}) => {
const results = await Promise.allSettled(
domainIDs.map((domainID) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
),
);
const failed = results.filter((r) => r.status === 'rejected');
const succeeded = results.length - failed.length;
return { succeeded, failed: failed.length, total: results.length };
},
onSuccess: async ({ succeeded, failed, total }) => {
if (failed === 0) {
toast.success(
total === 1
? '域名已加入并排队同步'
: `已添加 ${succeeded} 个域名并排队同步`,
);
} else if (succeeded === 0) {
toast.error(`添加失败:${failed} 个域名未能加入`);
} else {
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`);
}
setAddOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const proxiedMutation = useMutation({
mutationFn: ({
memberID,
proxied,
}: {
memberID: number;
proxied: boolean;
}) => CloudflareService.updateMember(groupID, memberID, proxied),
onSuccess: async () => {
toast.success('橙云设置已更新并排队同步');
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const syncMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.syncMember(groupID, memberID),
onSuccess: () => toast.success('成员同步任务已入队'),
onError: (error) => toast.error(getErrorMessage(error)),
});
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => {
toast.success('成员及远端 A 记录已删除');
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
if (detailQuery.isLoading)
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
</div>
);
if (detailQuery.isError || !detailQuery.data)
return (
<div className='w-full py-6 px-1'>
<ErrorInline
message={getErrorMessage(detailQuery.error)}
onRetry={() => void detailQuery.refetch()}
/>
</div>
);
const { group, members } = detailQuery.data;
/** Placeholder for static export; real ids resolve client-side via useParams. */
export async function generateStaticParams() {
return [{ id: '1' }];
}
export default async function CloudflareGroupDetailPage() {
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{group.name}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void detailQuery.refetch()}
disabled={detailQuery.isFetching}
>
{detailQuery.isFetching ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setEditOpen(true)}
>
<Settings data-icon='inline-start' />
编辑
</Button>
<Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' />
添加域名
</Button>
</div>
<Suspense
fallback={
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder
icon={Cloud}
description='加载分组详情中...'
/>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>当前指向</CardTitle>
<CardDescription>
生效节点 {group.active_node.name} · {group.active_node.ip}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '同步已启用' : '同步已停用'}
</Badge>
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
<Badge variant='outline'>
备用节点 {group.backup_node?.name ?? '未设置'}
</Badge>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>域名成员</CardTitle>
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
</CardHeader>
<CardContent>
{members.length === 0 ? (
<p className='py-8 text-center text-sm text-muted-foreground'>
暂无域名成员。
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>域名</TableHead>
<TableHead>期望 IP</TableHead>
<TableHead>状态</TableHead>
<TableHead>橙云</TableHead>
<TableHead className='text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className='font-medium'>{member.domain}</div>
{member.last_error ? (
<p className='max-w-md text-xs text-destructive'>
{member.last_error}
</p>
) : null}
</TableCell>
<TableCell>{member.desired_ip || '待同步'}</TableCell>
<TableCell>
<Badge
variant={
member.sync_status === 'ok'
? 'default'
: member.sync_status === 'error'
? 'destructive'
: 'secondary'
}
>
{member.sync_status}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={member.proxied}
onCheckedChange={(proxied) =>
proxiedMutation.mutate({
memberID: member.id,
proxied,
})
}
/>
</TableCell>
<TableCell>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
同步
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
移出
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<GroupDialog
open={editOpen}
onOpenChange={setEditOpen}
group={group}
nodes={nodesQuery.data ?? []}
pending={updateGroupMutation.isPending}
onSubmit={(payload) => updateGroupMutation.mutate(payload)}
/>
<MemberAddDialog
open={addOpen}
onOpenChange={setAddOpen}
domains={domainsQuery.data ?? []}
defaultProxied={group.default_proxied}
pending={addMutation.isPending}
onSubmit={(domainIDs, proxied) =>
addMutation.mutate({ domainIDs, proxied })
}
/>
</div>
}
>
<CloudflareGroupDetailPageClient />
</Suspense>
);
}