mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(cloudflare): add domain move and batch operations in group detail page
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
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 type {
|
||||||
|
CloudflareGroup,
|
||||||
|
CloudflareMember,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export interface MemberMoveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
members: CloudflareMember[];
|
||||||
|
groups: CloudflareGroup[];
|
||||||
|
currentGroupId: number;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: (targetGroupId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberMoveDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
members,
|
||||||
|
groups,
|
||||||
|
currentGroupId,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
}: MemberMoveDialogProps) {
|
||||||
|
const t = useTranslations('cloudflare');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const [targetGroupId, setTargetGroupId] = useState('');
|
||||||
|
|
||||||
|
const availableGroups = useMemo(
|
||||||
|
() => groups.filter((g) => g.id !== currentGroupId),
|
||||||
|
[groups, currentGroupId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTargetGroupId('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
members.length === 1
|
||||||
|
? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' })
|
||||||
|
: t('moveDialog.titleBatch', { count: members.length });
|
||||||
|
|
||||||
|
const description =
|
||||||
|
members.length > 0
|
||||||
|
? members.map((m) => m.domain).join(', ')
|
||||||
|
: t('moveDialog.targetGroupLabel');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription className='line-clamp-2'>
|
||||||
|
{description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
{availableGroups.length === 0 ? (
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
{t('moveDialog.noOtherGroups')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-move-target-group'>
|
||||||
|
{t('moveDialog.targetGroupLabel')}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={targetGroupId} onValueChange={setTargetGroupId}>
|
||||||
|
<SelectTrigger id='cf-move-target-group' className='w-full'>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t('moveDialog.targetGroupPlaceholder')}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
{group.primary_node?.ip
|
||||||
|
? ` · ${group.primary_node.ip}`
|
||||||
|
: ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pending || !targetGroupId || availableGroups.length === 0}
|
||||||
|
onClick={() => onSubmit(Number(targetGroupId))}
|
||||||
|
>
|
||||||
|
{pending ? t('moveDialog.moving') : t('moveDialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
Cloud,
|
Cloud,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -16,6 +17,16 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
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 {
|
import {
|
||||||
@@ -25,6 +36,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -41,10 +53,12 @@ import {
|
|||||||
cloudflareQueryKey,
|
cloudflareQueryKey,
|
||||||
NodeService,
|
NodeService,
|
||||||
type CloudflareGroupPayload,
|
type CloudflareGroupPayload,
|
||||||
|
type CloudflareMember,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
import { getErrorMessage } from '../../../websites/components/website-utils';
|
import { getErrorMessage } from '../../../websites/components/website-utils';
|
||||||
import { GroupDialog } from '../../components/group-dialog';
|
import { GroupDialog } from '../../components/group-dialog';
|
||||||
import { MemberAddDialog } from '../../components/member-add-dialog';
|
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||||
|
import { MemberMoveDialog } from '../../components/member-move-dialog';
|
||||||
|
|
||||||
function getGroupIdFromPathname(pathname: string | null): number {
|
function getGroupIdFromPathname(pathname: string | null): number {
|
||||||
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
|
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
|
||||||
@@ -53,6 +67,7 @@ function getGroupIdFromPathname(pathname: string | null): number {
|
|||||||
|
|
||||||
export function CloudflareGroupDetailPageClient() {
|
export function CloudflareGroupDetailPageClient() {
|
||||||
const t = useTranslations('cloudflare');
|
const t = useTranslations('cloudflare');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -63,6 +78,13 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [selectedMemberIDs, setSelectedMemberIDs] = useState<Set<number>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||||
|
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
|
||||||
|
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false);
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
||||||
queryFn: () => CloudflareService.getGroup(groupID),
|
queryFn: () => CloudflareService.getGroup(groupID),
|
||||||
@@ -77,6 +99,10 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
queryKey: ['openflare', 'nodes'],
|
queryKey: ['openflare', 'nodes'],
|
||||||
queryFn: () => NodeService.listNodes(),
|
queryFn: () => NodeService.listNodes(),
|
||||||
});
|
});
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'groups'],
|
||||||
|
queryFn: () => CloudflareService.listGroups(),
|
||||||
|
});
|
||||||
const invalidate = async () =>
|
const invalidate = async () =>
|
||||||
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
|
||||||
@@ -150,12 +176,80 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (memberID: number) =>
|
mutationFn: (memberID: number) =>
|
||||||
CloudflareService.removeMember(groupID, memberID),
|
CloudflareService.removeMember(groupID, memberID),
|
||||||
onSuccess: async () => {
|
onSuccess: async (_, memberID) => {
|
||||||
toast.success(t('memberDeleted'));
|
toast.success(t('memberDeleted'));
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(memberID);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
targetGroupId,
|
||||||
|
membersToMove,
|
||||||
|
}: {
|
||||||
|
targetGroupId: number;
|
||||||
|
membersToMove: CloudflareMember[];
|
||||||
|
}) => {
|
||||||
|
if (membersToMove.length === 1) {
|
||||||
|
return CloudflareService.moveMember(
|
||||||
|
groupID,
|
||||||
|
membersToMove[0].id,
|
||||||
|
targetGroupId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CloudflareService.batchMoveMembers(
|
||||||
|
groupID,
|
||||||
|
membersToMove.map((m) => m.id),
|
||||||
|
targetGroupId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async (_, variables) => {
|
||||||
|
if (variables.membersToMove.length === 1) {
|
||||||
|
toast.success(t('memberMoved'));
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t('batchMoved', { count: variables.membersToMove.length }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setMoveDialogOpen(false);
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const m of variables.membersToMove) {
|
||||||
|
next.delete(m.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const batchRemoveMutation = useMutation({
|
||||||
|
mutationFn: (memberIds: number[]) =>
|
||||||
|
CloudflareService.batchRemoveMembers(groupID, memberIds),
|
||||||
|
onSuccess: async (_, memberIds) => {
|
||||||
|
toast.success(t('batchRemoved', { count: memberIds.length }));
|
||||||
|
setBatchRemoveOpen(false);
|
||||||
|
setSelectedMemberIDs(new Set());
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSingleMove = (member: CloudflareMember) => {
|
||||||
|
setMovingMembers([member]);
|
||||||
|
setMoveDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchMove = (selectedList: CloudflareMember[]) => {
|
||||||
|
if (selectedList.length === 0) return;
|
||||||
|
setMovingMembers(selectedList);
|
||||||
|
setMoveDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted || detailQuery.isLoading)
|
if (!mounted || detailQuery.isLoading)
|
||||||
return (
|
return (
|
||||||
@@ -174,6 +268,12 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
);
|
);
|
||||||
const { group, members } = detailQuery.data;
|
const { group, members } = detailQuery.data;
|
||||||
|
|
||||||
|
const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id));
|
||||||
|
const allSelected =
|
||||||
|
members.length > 0 && selectedMembers.length === members.length;
|
||||||
|
const isIndeterminate =
|
||||||
|
selectedMembers.length > 0 && selectedMembers.length < members.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
@@ -246,9 +346,41 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className='border-dashed shadow-none'>
|
<Card className='border-dashed shadow-none'>
|
||||||
<CardHeader>
|
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
<div>
|
||||||
<CardDescription>{t('membersDesc')}</CardDescription>
|
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
||||||
|
<CardDescription>{t('membersDesc')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Badge variant='secondary' className='text-xs'>
|
||||||
|
{t('selectedCount', { count: selectedMembers.length })}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleBatchMove(selectedMembers)}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft data-icon='inline-start' />
|
||||||
|
{t('batchMove')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setBatchRemoveOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
{t('batchRemove')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setSelectedMemberIDs(new Set())}
|
||||||
|
>
|
||||||
|
{t('clearSelection')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
@@ -259,6 +391,27 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className='w-12'>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
allSelected
|
||||||
|
? true
|
||||||
|
: isIndeterminate
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedMemberIDs(
|
||||||
|
new Set(members.map((m) => m.id)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedMemberIDs(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label='Select all'
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>{t('columns.domain')}</TableHead>
|
<TableHead>{t('columns.domain')}</TableHead>
|
||||||
<TableHead>{t('columns.desiredIp')}</TableHead>
|
<TableHead>{t('columns.desiredIp')}</TableHead>
|
||||||
<TableHead>{t('columns.status')}</TableHead>
|
<TableHead>{t('columns.status')}</TableHead>
|
||||||
@@ -270,7 +423,29 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow
|
||||||
|
key={member.id}
|
||||||
|
data-state={
|
||||||
|
selectedMemberIDs.has(member.id) ? 'selected' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className='w-12'>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedMemberIDs.has(member.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) {
|
||||||
|
next.add(member.id);
|
||||||
|
} else {
|
||||||
|
next.delete(member.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={`Select ${member.domain}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='font-medium'>{member.domain}</div>
|
<div className='font-medium'>{member.domain}</div>
|
||||||
{member.last_error ? (
|
{member.last_error ? (
|
||||||
@@ -308,6 +483,14 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='flex justify-end gap-2'>
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleSingleMove(member)}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft data-icon='inline-start' />
|
||||||
|
{t('move')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -352,6 +535,43 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
addMutation.mutate({ domainIDs, proxied })
|
addMutation.mutate({ domainIDs, proxied })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<MemberMoveDialog
|
||||||
|
open={moveDialogOpen}
|
||||||
|
onOpenChange={setMoveDialogOpen}
|
||||||
|
members={movingMembers}
|
||||||
|
groups={groupsQuery.data ?? []}
|
||||||
|
currentGroupId={groupID}
|
||||||
|
pending={moveMutation.isPending}
|
||||||
|
onSubmit={(targetGroupId) =>
|
||||||
|
moveMutation.mutate({
|
||||||
|
targetGroupId,
|
||||||
|
membersToMove: movingMembers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={batchRemoveMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{batchRemoveMutation.isPending
|
||||||
|
? t('batchRemoveDialog.removing')
|
||||||
|
: t('batchRemoveDialog.confirm')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,4 +102,35 @@ export class CloudflareService extends OpenFlareBaseService {
|
|||||||
`/groups/${groupId}/members/${memberId}/sync`,
|
`/groups/${groupId}/members/${memberId}/sync`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static moveMember(
|
||||||
|
groupId: number,
|
||||||
|
memberId: number,
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<CloudflareMember> {
|
||||||
|
return this.post<CloudflareMember>(
|
||||||
|
`/groups/${groupId}/members/${memberId}/move`,
|
||||||
|
{ target_group_id: targetGroupId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static batchMoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${groupId}/members/batch-move`, {
|
||||||
|
member_ids: memberIds,
|
||||||
|
target_group_id: targetGroupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static batchRemoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${groupId}/members/batch-remove`, {
|
||||||
|
member_ids: memberIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cloudflare": {
|
||||||
|
"move": "Move",
|
||||||
|
"batchMove": "Batch Move",
|
||||||
|
"batchRemove": "Batch Delete",
|
||||||
|
"selectedCount": "{count} selected",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"moveDialog": {
|
||||||
|
"titleSingle": "Move domain \"{domain}\"",
|
||||||
|
"titleBatch": "Batch move domains ({count} items)",
|
||||||
|
"targetGroupLabel": "Target pointing group",
|
||||||
|
"targetGroupPlaceholder": "Select target group",
|
||||||
|
"noOtherGroups": "No other pointing groups available. Please create one first.",
|
||||||
|
"submit": "Confirm move",
|
||||||
|
"moving": "Moving..."
|
||||||
|
},
|
||||||
|
"batchRemoveDialog": {
|
||||||
|
"title": "Confirm batch domain deletion",
|
||||||
|
"desc": "Are you sure you want to remove {count} selected domains from the current group? This will also clean up DNS records on Cloudflare.",
|
||||||
|
"confirm": "Confirm delete",
|
||||||
|
"removing": "Deleting..."
|
||||||
|
},
|
||||||
|
"memberMoved": "Domain moved successfully",
|
||||||
|
"batchMoved": "Successfully moved {count} domains",
|
||||||
|
"batchRemoved": "Successfully deleted {count} domains"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cloudflare": {
|
||||||
|
"move": "移动",
|
||||||
|
"batchMove": "批量移动",
|
||||||
|
"batchRemove": "批量删除",
|
||||||
|
"selectedCount": "已选择 {count} 项",
|
||||||
|
"clearSelection": "取消选择",
|
||||||
|
"moveDialog": {
|
||||||
|
"titleSingle": "移动域名「{domain}」",
|
||||||
|
"titleBatch": "批量移动域名(共 {count} 项)",
|
||||||
|
"targetGroupLabel": "目标指向分组",
|
||||||
|
"targetGroupPlaceholder": "请选择目标分组",
|
||||||
|
"noOtherGroups": "没有其他可用的指向分组,请先创建分组",
|
||||||
|
"submit": "确认移动",
|
||||||
|
"moving": "移动中..."
|
||||||
|
},
|
||||||
|
"batchRemoveDialog": {
|
||||||
|
"title": "确认批量删除域名",
|
||||||
|
"desc": "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。",
|
||||||
|
"confirm": "确认删除",
|
||||||
|
"removing": "删除中..."
|
||||||
|
},
|
||||||
|
"memberMoved": "域名移动成功",
|
||||||
|
"batchMoved": "成功移动 {count} 个域名",
|
||||||
|
"batchRemoved": "成功删除 {count} 个域名"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,17 +39,74 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
|||||||
CloudflareService: {
|
CloudflareService: {
|
||||||
...actual.CloudflareService,
|
...actual.CloudflareService,
|
||||||
getGroup: vi.fn(),
|
getGroup: vi.fn(),
|
||||||
|
listGroups: vi.fn(),
|
||||||
listAvailableDomains: vi.fn(),
|
listAvailableDomains: vi.fn(),
|
||||||
updateGroup: vi.fn(),
|
updateGroup: vi.fn(),
|
||||||
createMember: vi.fn(),
|
createMember: vi.fn(),
|
||||||
updateMember: vi.fn(),
|
updateMember: vi.fn(),
|
||||||
syncMember: vi.fn(),
|
syncMember: vi.fn(),
|
||||||
removeMember: vi.fn(),
|
removeMember: vi.fn(),
|
||||||
|
moveMember: vi.fn(),
|
||||||
|
batchMoveMembers: vi.fn(),
|
||||||
|
batchRemoveMembers: vi.fn(),
|
||||||
},
|
},
|
||||||
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
|
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockGroup = {
|
||||||
|
id: 7,
|
||||||
|
name: '生产节点',
|
||||||
|
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
backup_node: null,
|
||||||
|
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
default_proxied: true,
|
||||||
|
enabled: true,
|
||||||
|
member_count: 2,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTargetGroup = {
|
||||||
|
id: 8,
|
||||||
|
name: '备用分组',
|
||||||
|
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
|
backup_node: null,
|
||||||
|
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
|
default_proxied: false,
|
||||||
|
enabled: true,
|
||||||
|
member_count: 0,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMembers = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
group_id: 7,
|
||||||
|
zone_domain_id: 1,
|
||||||
|
domain: 'a.example.com',
|
||||||
|
zone_id: 10,
|
||||||
|
proxied: true,
|
||||||
|
desired_ip: '192.0.2.1',
|
||||||
|
sync_status: 'ok' as const,
|
||||||
|
last_error: '',
|
||||||
|
synced_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
group_id: 7,
|
||||||
|
zone_domain_id: 2,
|
||||||
|
domain: 'b.example.com',
|
||||||
|
zone_id: 10,
|
||||||
|
proxied: false,
|
||||||
|
desired_ip: '192.0.2.1',
|
||||||
|
sync_status: 'ok' as const,
|
||||||
|
last_error: '',
|
||||||
|
synced_at: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||||
@@ -72,25 +129,36 @@ describe('Cloudflare group detail refresh', () => {
|
|||||||
mockGroupId = '7';
|
mockGroupId = '7';
|
||||||
mockParamId = '7';
|
mockParamId = '7';
|
||||||
vi.mocked(CloudflareService.getGroup).mockReset();
|
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||||
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||||
vi.mocked(NodeService.listNodes).mockReset();
|
vi.mocked(NodeService.listNodes).mockReset();
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||||
|
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||||
group: {
|
group: mockGroup,
|
||||||
id: 7,
|
|
||||||
name: '生产节点',
|
|
||||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
|
||||||
backup_node: null,
|
|
||||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
|
||||||
default_proxied: true,
|
|
||||||
enabled: true,
|
|
||||||
member_count: 0,
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
},
|
|
||||||
members: [],
|
members: [],
|
||||||
});
|
});
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||||
|
mockGroup,
|
||||||
|
mockTargetGroup,
|
||||||
|
]);
|
||||||
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||||
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||||
|
...mockMembers[0],
|
||||||
|
group_id: 8,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -141,3 +209,174 @@ describe('Cloudflare group detail refresh', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Cloudflare group detail move and batch operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGroupId = '7';
|
||||||
|
mockParamId = '7';
|
||||||
|
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||||
|
vi.mocked(NodeService.listNodes).mockReset();
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||||
|
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||||
|
group: mockGroup,
|
||||||
|
members: mockMembers,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||||
|
mockGroup,
|
||||||
|
mockTargetGroup,
|
||||||
|
]);
|
||||||
|
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||||
|
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||||
|
...mockMembers[0],
|
||||||
|
group_id: 8,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports selecting all members and toggling selection', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
expect(screen.getByText('b.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Initially batch toolbar is not visible
|
||||||
|
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Select all via header checkbox
|
||||||
|
const selectAllCheckbox = screen.getByRole('checkbox', {
|
||||||
|
name: 'Select all',
|
||||||
|
});
|
||||||
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
|
// Batch toolbar should appear with count 2
|
||||||
|
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '批量移动' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '批量删除' })).toBeVisible();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '取消选择' }));
|
||||||
|
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens single move dialog and triggers moveMember', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Click single move button on first row
|
||||||
|
const moveButtons = screen.getAllByRole('button', { name: '移动' });
|
||||||
|
fireEvent.click(moveButtons[0]);
|
||||||
|
|
||||||
|
// Move dialog should show single member title
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: '移动域名「a.example.com」',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Open target group select
|
||||||
|
const selectTrigger = screen.getByRole('combobox');
|
||||||
|
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
// Select target group
|
||||||
|
const targetOption = await screen.findByRole('option', {
|
||||||
|
name: /备用分组/,
|
||||||
|
});
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
// Submit move
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.moveMember).toHaveBeenCalledWith(7, 101, 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens batch move dialog and triggers batchMoveMembers', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Select all members
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' }));
|
||||||
|
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||||
|
|
||||||
|
// Click batch move button
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '批量移动' }));
|
||||||
|
|
||||||
|
// Dialog title should show batch title
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: '批量移动域名(共 2 项)',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Open target group select
|
||||||
|
const selectTrigger = screen.getByRole('combobox');
|
||||||
|
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
// Select target group
|
||||||
|
const targetOption = await screen.findByRole('option', {
|
||||||
|
name: /备用分组/,
|
||||||
|
});
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
// Submit move
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.batchMoveMembers).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
[101, 102],
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens batch remove dialog and triggers batchRemoveMembers', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Select first member only
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('checkbox', { name: 'Select a.example.com' }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('已选择 1 项')).toBeVisible();
|
||||||
|
|
||||||
|
// Click batch remove
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '批量删除' }));
|
||||||
|
|
||||||
|
// Confirmation dialog appears
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: '确认批量删除域名' }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/确认从当前分组移出已选中的 1 个域名吗?/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.batchRemoveMembers).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
[101],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,4 +55,29 @@ describe('CloudflareService', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls move and batch member endpoints with correct paths and payloads', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||||
|
|
||||||
|
await CloudflareService.moveMember(7, 9, 8);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/9/move',
|
||||||
|
{ target_group_id: 8 },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CloudflareService.batchMoveMembers(7, [9, 10], 8);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/batch-move',
|
||||||
|
{ member_ids: [9, 10], target_group_id: 8 },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CloudflareService.batchRemoveMembers(7, [9, 10]);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/batch-remove',
|
||||||
|
{ member_ids: [9, 10] },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user