@@ -246,9 +346,41 @@ export function CloudflareGroupDetailPageClient() {
-
- {t('members')}
- {t('membersDesc')}
+
+
+ {t('members')}
+ {t('membersDesc')}
+
+ {selectedMembers.length > 0 && (
+
+
+ {t('selectedCount', { count: selectedMembers.length })}
+
+
+
+
+
+ )}
{members.length === 0 ? (
@@ -259,6 +391,27 @@ export function CloudflareGroupDetailPageClient() {
+
+ {
+ if (checked) {
+ setSelectedMemberIDs(
+ new Set(members.map((m) => m.id)),
+ );
+ } else {
+ setSelectedMemberIDs(new Set());
+ }
+ }}
+ aria-label='Select all'
+ />
+
{t('columns.domain')}
{t('columns.desiredIp')}
{t('columns.status')}
@@ -270,7 +423,29 @@ export function CloudflareGroupDetailPageClient() {
{members.map((member) => (
-
+
+
+ {
+ 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}`}
+ />
+
{member.domain}
{member.last_error ? (
@@ -308,6 +483,14 @@ export function CloudflareGroupDetailPageClient() {
+
+
+ moveMutation.mutate({
+ targetGroupId,
+ membersToMove: movingMembers,
+ })
+ }
+ />
+
+
+
+ {t('batchRemoveDialog.title')}
+
+ {t('batchRemoveDialog.desc', { count: selectedMembers.length })}
+
+
+
+ {tCommon('cancel')}
+
+ batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
+ }
+ >
+ {batchRemoveMutation.isPending
+ ? t('batchRemoveDialog.removing')
+ : t('batchRemoveDialog.confirm')}
+
+
+
+
);
}
diff --git a/frontend/lib/services/openflare/cloudflare.service.ts b/frontend/lib/services/openflare/cloudflare.service.ts
index 29fa7bbb..910baa05 100644
--- a/frontend/lib/services/openflare/cloudflare.service.ts
+++ b/frontend/lib/services/openflare/cloudflare.service.ts
@@ -102,4 +102,35 @@ export class CloudflareService extends OpenFlareBaseService {
`/groups/${groupId}/members/${memberId}/sync`,
);
}
+
+ static moveMember(
+ groupId: number,
+ memberId: number,
+ targetGroupId: number,
+ ): Promise {
+ return this.post(
+ `/groups/${groupId}/members/${memberId}/move`,
+ { target_group_id: targetGroupId },
+ );
+ }
+
+ static batchMoveMembers(
+ groupId: number,
+ memberIds: number[],
+ targetGroupId: number,
+ ): Promise {
+ return this.post(`/groups/${groupId}/members/batch-move`, {
+ member_ids: memberIds,
+ target_group_id: targetGroupId,
+ });
+ }
+
+ static batchRemoveMembers(
+ groupId: number,
+ memberIds: number[],
+ ): Promise {
+ return this.post(`/groups/${groupId}/members/batch-remove`, {
+ member_ids: memberIds,
+ });
+ }
}
diff --git a/frontend/messages/fragments/cloudflare.en.json b/frontend/messages/fragments/cloudflare.en.json
new file mode 100644
index 00000000..55084996
--- /dev/null
+++ b/frontend/messages/fragments/cloudflare.en.json
@@ -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"
+ }
+}
diff --git a/frontend/messages/fragments/cloudflare.zh-CN.json b/frontend/messages/fragments/cloudflare.zh-CN.json
new file mode 100644
index 00000000..c42a8d80
--- /dev/null
+++ b/frontend/messages/fragments/cloudflare.zh-CN.json
@@ -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} 个域名"
+ }
+}
diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
index e849dc73..4004ab33 100644
--- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
+++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
@@ -39,17 +39,74 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
CloudflareService: {
...actual.CloudflareService,
getGroup: vi.fn(),
+ listGroups: vi.fn(),
listAvailableDomains: vi.fn(),
updateGroup: vi.fn(),
createMember: vi.fn(),
updateMember: vi.fn(),
syncMember: vi.fn(),
removeMember: vi.fn(),
+ moveMember: vi.fn(),
+ batchMoveMembers: vi.fn(),
+ batchRemoveMembers: 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() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
@@ -72,25 +129,36 @@ describe('Cloudflare group detail refresh', () => {
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: {
- 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: '',
- },
+ group: mockGroup,
members: [],
});
+ 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,
+ );
});
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],
+ );
+ });
+ });
+});
diff --git a/frontend/tests/cloudflare/cloudflare-service.test.ts b/frontend/tests/cloudflare/cloudflare-service.test.ts
index 82e26bf7..a95425ad 100644
--- a/frontend/tests/cloudflare/cloudflare-service.test.ts
+++ b/frontend/tests/cloudflare/cloudflare-service.test.ts
@@ -55,4 +55,29 @@ describe('CloudflareService', () => {
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,
+ );
+ });
});