This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 05bba9159 feat(instance): batch delete, CORS diagnostics and 
navigation refinements
05bba9159 is described below

commit 05bba9159a7b83a325c06ff704efd4b504472e9f
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 20 10:44:05 2026 +0800

    feat(instance): batch delete, CORS diagnostics and navigation refinements
    
    Multi-select with a toolbar delete button deletes instances in one
    request; cloud instances bypass the managed-resource guard while
    APACHE instances keep it, and delete failures surface the backend
    reason. Compose passes STUDIO_CORS_ALLOWED_ORIGINS through and the
    frontend toasts an actionable hint for CORS-rejected requests. Topic
    detail subscription groups link to the consumer page with ?group=
    pre-filled, and the instance list navigates only via the (black)
    instance ID cell.
---
 deploy/docker-compose.yml                          |  1 +
 .../studio/instance/BatchDeleteInstancesDTO.java   | 30 ++++++++
 .../studio/instance/BatchDeleteResultVO.java       | 36 ++++++++++
 .../studio/instance/InstanceController.java        |  5 ++
 .../rocketmq/studio/instance/InstanceService.java  | 42 +++++++++---
 .../studio/instance/InstanceServiceTest.java       | 40 +++++++++++
 web/src/api/client.test.ts                         | 18 +++++
 web/src/api/client.ts                              | 26 +++++++
 web/src/api/instance.ts                            | 10 +++
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  9 +++
 .../pages/instance/__tests__/InstancePage.test.tsx | 31 +++++++++
 .../pages/instance/__tests__/TopicPage.test.tsx    | 25 +++++++
 web/src/pages/instance/consumer.tsx                |  4 +-
 web/src/pages/instance/index.tsx                   | 80 ++++++++++++++++++----
 web/src/pages/instance/topic.tsx                   | 22 +++++-
 web/src/services/instanceService.ts                | 15 ++++
 16 files changed, 369 insertions(+), 25 deletions(-)

diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index d68278bcf..ed75bba5e 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -40,6 +40,7 @@ services:
       STUDIO_AUTH_LOGIN_REQUIRED: ${STUDIO_AUTH_LOGIN_REQUIRED:-true}
       STUDIO_AUTH_ADMIN_USERNAME: ${STUDIO_AUTH_ADMIN_USERNAME:-}
       STUDIO_AUTH_ADMIN_PASSWORD: ${STUDIO_AUTH_ADMIN_PASSWORD:-}
+      STUDIO_CORS_ALLOWED_ORIGINS: 
${STUDIO_CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
       STUDIO_METRICS_PROMETHEUS_BASE_URL: 
${STUDIO_METRICS_PROMETHEUS_BASE_URL:-}
       STUDIO_METRICS_PROMETHEUS_USERNAME: 
${STUDIO_METRICS_PROMETHEUS_USERNAME:-}
       STUDIO_METRICS_PROMETHEUS_PASSWORD: 
${STUDIO_METRICS_PROMETHEUS_PASSWORD:-}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
new file mode 100644
index 000000000..8d5c9e907
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance;
+
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class BatchDeleteInstancesDTO {
+
+    @NotEmpty
+    private List<String> ids;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
new file mode 100644
index 000000000..eded99d18
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BatchDeleteResultVO {
+
+    private int deleted;
+
+    private List<String> failed;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
index 2d271b5a1..4338dcd7d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
@@ -75,4 +75,9 @@ public class InstanceController {
         
instanceService.deleteInstance(instanceService.resolveInstanceId(request.getId()));
         return Result.ok();
     }
+
+    @PostMapping("/delete-batch")
+    public Result<BatchDeleteResultVO> deleteInstances(@Valid @RequestBody 
BatchDeleteInstancesDTO request) {
+        return Result.ok(instanceService.deleteInstances(request.getIds()));
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 7b4675eb1..cd0ecea03 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -448,14 +448,16 @@ public class InstanceService {
         InstanceVO existing = instanceRepository.findById(id)
                 .orElseThrow(() -> new BusinessException(404, "InstanceVO not 
found: " + id));
 
-        InstanceProvider provider = providerRegistry.forVendor(
-                existing.getVendor() == null ? InstanceVendor.APACHE : 
existing.getVendor());
-        int topicCount = provider.countTopics(String.valueOf(id));
-        int consumerGroupCount = provider.countGroups(String.valueOf(id));
-        if (topicCount > 0 || consumerGroupCount > 0) {
-            throw new BusinessException(409, String.format(
-                    "Cannot delete instance with managed resources: topics=%d, 
consumerGroups=%d",
-                    topicCount, consumerGroupCount));
+        InstanceVendor vendor = existing.getVendor() == null ? 
InstanceVendor.APACHE : existing.getVendor();
+        if (vendor == InstanceVendor.APACHE) {
+            InstanceProvider provider = 
providerRegistry.forVendor(InstanceVendor.APACHE);
+            int topicCount = provider.countTopics(String.valueOf(id));
+            int consumerGroupCount = provider.countGroups(String.valueOf(id));
+            if (topicCount > 0 || consumerGroupCount > 0) {
+                throw new BusinessException(409, String.format(
+                        "Cannot delete instance with managed resources: 
topics=%d, consumerGroups=%d",
+                        topicCount, consumerGroupCount));
+            }
         }
         if (!instanceRepository.deleteById(id)) {
             throw new BusinessException(404, "InstanceVO not found: " + id);
@@ -466,6 +468,30 @@ public class InstanceService {
                 instanceAuditDetail(existing));
     }
 
+    /**
+     * Deletes the selected instances one by one, collecting per-instance 
failures (for example
+     * an APACHE instance that still owns topics/groups) instead of aborting 
the whole batch.
+     */
+    public BatchDeleteResultVO deleteInstances(List<String> instanceIds) {
+        if (instanceIds == null || instanceIds.isEmpty()) {
+            throw new BusinessException(400, "Instance IDs are required");
+        }
+        int deleted = 0;
+        List<String> failed = new ArrayList<>();
+        for (String instanceId : instanceIds) {
+            if (instanceId == null || instanceId.isBlank()) {
+                continue;
+            }
+            try {
+                deleteInstance(resolveInstanceId(instanceId.trim()));
+                deleted++;
+            } catch (BusinessException ex) {
+                failed.add(instanceId + ": " + ex.getMessage());
+            }
+        }
+        return 
BatchDeleteResultVO.builder().deleted(deleted).failed(failed).build();
+    }
+
     private void removeDataSourceBindings(String instanceId) {
         for (DataSourceVO dataSource : 
settingsRepository.findAllDataSources()) {
             List<String> instanceIds = dataSource.getInstanceIds();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 7799e3034..c39a6240c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -717,6 +717,46 @@ class InstanceServiceTest {
                 eq("name=to-delete, vendor=APACHE, type=null"), eq("SUCCESS"), 
eq(null));
     }
 
+    @Test
+    void deleteInstanceShouldSkipResourceCheckForCloudInstancesTest() {
+        InstanceVO existing = 
InstanceVO.builder().name("cloud-inst").vendor(InstanceVendor.ALIYUN).build();
+        existing.setId(2L);
+        
when(instanceRepository.findById(2L)).thenReturn(Optional.of(existing));
+        when(instanceRepository.deleteById(2L)).thenReturn(true);
+
+        instanceService.deleteInstance(2L);
+
+        verify(instanceRepository).deleteById(2L);
+        verifyNoInteractions(providerRegistry);
+    }
+
+    @Test
+    void deleteInstancesShouldDeleteAndCollectFailuresTest() {
+        InstanceVO existing = InstanceVO.builder().name("inst-a").build();
+        existing.setId(1L);
+        
when(instanceRepository.findByIdentifier("inst-a")).thenReturn(Optional.of(existing));
+        when(instanceRepository.findByIdentifier("missing"))
+                .thenReturn(Optional.empty());
+        
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
+        
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+        when(instanceProvider.countTopics("1")).thenReturn(0);
+        when(instanceProvider.countGroups("1")).thenReturn(0);
+        when(instanceRepository.deleteById(1L)).thenReturn(true);
+
+        BatchDeleteResultVO result = 
instanceService.deleteInstances(List.of("inst-a", "missing"));
+
+        assertThat(result.getDeleted()).isEqualTo(1);
+        assertThat(result.getFailed()).containsExactly("missing: Instance not 
found: missing");
+    }
+
+    @Test
+    void deleteInstancesShouldRejectEmptySelectionTest() {
+        assertThatThrownBy(() -> instanceService.deleteInstances(List.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Instance IDs are required")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+    }
+
     @Test
     void deleteInstanceShouldRejectInstanceWithTopics() {
         InstanceVO existing = InstanceVO.builder()
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index 2aecc767b..7701579e7 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -162,6 +162,24 @@ describe('API client response contract', () => {
     mock.onGet('http://[').reply(401, { code: 401, message: 'Unauthorized', 
data: null });
 
     await expect(client.get('http://[')).rejects.toMatchObject({ response: { 
status: 401 } });
+  });
+
+  it('surfaces an actionable hint when the server rejects the origin via 
CORS', async () => {
+    mock.onPost('/instances/delete').reply(403, 'Invalid CORS request');
 
+    await expect(client.post('/instances/delete', { id: 'x' 
})).rejects.toThrow(/CORS/);
+    expect(message.error).toHaveBeenCalledWith(
+      expect.stringContaining('STUDIO_CORS_ALLOWED_ORIGINS'),
+    );
+  });
+
+  it('does not treat a business-envelope 403 as a CORS rejection', async () => 
{
+    mock
+      .onPost('/instances/delete')
+      .reply(403, { code: 403, message: 'Admin permission required', data: 
null });
+
+    await expect(client.post('/instances/delete', { id: 'x' 
})).rejects.toThrow(
+      'Admin permission required',
+    );
   });
 });
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 4fe9f5e53..ddc4c6e15 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -46,6 +46,25 @@ function getBusinessError(data: unknown): string | null {
   return typeof data.message === 'string' && data.message.trim() ? 
data.message : '请求失败';
 }
 
+const CORS_REJECTION_HINT =
+  '请求被服务端 CORS 策略拒绝(Invalid CORS request):当前访问地址不在后端白名单,请检查部署的 
STUDIO_CORS_ALLOWED_ORIGINS 配置';
+
+/**
+ * Spring CORS rejects non-whitelisted origins with 403 and a plain-text body 
(often
+ * unreadable in the browser), while every application-level 403 carries the 
JSON
+ * business envelope — a 403 without that envelope is a CORS rejection.
+ */
+function isCorsRejection(error: unknown): boolean {
+  if (!axios.isAxiosError(error) || error.response?.status !== 403) {
+    return false;
+  }
+  const data = error.response.data;
+  if (data === undefined || data === null || data === '') {
+    return true;
+  }
+  return typeof data === 'string' && /cors/i.test(data);
+}
+
 function isPublicAuthRequest(url?: string): boolean {
   if (!url) return false;
   try {
@@ -83,6 +102,13 @@ client.interceptors.response.use(
       window.location.href = '/';
       return Promise.reject(error);
     }
+    if (isCorsRejection(error)) {
+      message.error(CORS_REJECTION_HINT);
+      if (error instanceof Error) {
+        error.message = CORS_REJECTION_HINT;
+      }
+      return Promise.reject(error);
+    }
     const errorMessage = getBusinessError(error.response?.data);
     if (errorMessage) {
       message.error(errorMessage);
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index bd9f1515a..f071f4883 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -124,6 +124,16 @@ export async function deleteInstance(instanceId: string) {
   await client.post('/instances/delete', { id: instanceId });
 }
 
+export interface BatchDeleteResult {
+  deleted: number;
+  failed: string[];
+}
+
+export async function deleteInstancesBatch(ids: string[]) {
+  const res = await client.post<{ data: BatchDeleteResult 
}>('/instances/delete-batch', { ids });
+  return res.data.data;
+}
+
 export async function importCloudInstances(data: { vendor: InstanceVendor; 
credentialId: number }) {
   const res = await client.post<{ data: CloudImportResult 
}>('/instances/import-cloud', data);
   return res.data.data;
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 0982121e7..1ee7fd6e2 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -200,6 +200,15 @@ describe('Consumer page', () => {
     });
   });
 
+  it('prefills the group search from the ?group= query parameter', async () => 
{
+    renderWithProviders(<ConsumerPage />, 
'/instance/consumer?group=remote-cg');
+
+    expect(await screen.findByText('remote-cg')).toBeInTheDocument();
+    expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+      expect.objectContaining({ search: 'remote-cg' }),
+    );
+  });
+
   it('downloads the currently filtered consumer groups when exporting', async 
() => {
     const user = userEvent.setup();
     const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(vi.fn());
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index e8a7516cd..8c7f9d6b8 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -45,6 +45,7 @@ vi.mock('../../../api/tencentCatalog', () => ({
 vi.mock('../../../services/instanceService', () => ({
   createInstance: vi.fn(),
   deleteInstance: vi.fn(),
+  deleteInstancesBatch: vi.fn(),
   importCloudInstances: vi.fn(),
   listInstances: vi.fn(),
   updateInstance: vi.fn(),
@@ -773,4 +774,34 @@ describe('InstancePage', () => {
     expect(within(apacheRow).getAllByText('-').length).toBeGreaterThan(0);
     
expect(within(apacheRow).queryByText('cn-hangzhou')).not.toBeInTheDocument();
   });
+
+  it('deletes selected instances through the toolbar batch delete button', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(instanceService.listInstances).mockResolvedValue([
+      instance(20, 'batch-a'),
+      instance(21, 'batch-b'),
+    ]);
+    vi.mocked(instanceService.deleteInstancesBatch).mockResolvedValue({ 
deleted: 1, failed: [] });
+    const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) 
=> {
+      void config.onOk?.();
+      return { destroy: vi.fn(), update: vi.fn() } as unknown as 
ReturnType<typeof Modal.confirm>;
+    });
+    renderPage();
+
+    await screen.findByText('batch-a');
+    const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
+    expect(deleteButton).toBeDisabled();
+
+    const checkboxes = screen.getAllByRole('checkbox');
+    await user.click(checkboxes[1]);
+    await waitFor(() => expect(deleteButton).toBeEnabled());
+
+    await user.click(deleteButton);
+
+    await waitFor(() =>
+      
expect(instanceService.deleteInstancesBatch).toHaveBeenCalledWith(['batch-a']),
+    );
+    expect(confirmSpy).toHaveBeenCalled();
+    confirmSpy.mockRestore();
+  });
 });
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 7c97b2009..5e65897f3 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -519,4 +519,29 @@ describe('TopicPage', () => {
     );
     expect(await screen.findAllByText('不可用')).not.toHaveLength(0);
   });
+
+  it('renders subscription group names as links in the topic detail modal', 
async () => {
+    const user = userEvent.setup();
+    mockTopicsList([buildTopics(1)[0]]);
+    topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
+      items: [
+        {
+          group: 'cg-orders',
+          consumeType: 'CLUSTERING',
+          messageModel: 'CLUSTERING',
+          consumeTps: 5,
+          diffTotal: 0,
+        },
+      ],
+      total: 1,
+      page: 1,
+      pageSize: 20,
+    });
+    renderWithProviders();
+
+    await user.click(await screen.findByRole('button', { name: /详情/ }));
+
+    const groupLink = await screen.findByText('cg-orders');
+    expect(groupLink.closest('a')).not.toBeNull();
+  });
 });
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 9338a5ba9..2a5259f5c 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -16,6 +16,7 @@
  */
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
 import {
   Alert,
   Table,
@@ -182,7 +183,8 @@ const ConsumerPageContent = ({
   const [submitting, setSubmitting] = useState(false);
   const [resetSubmitting, setResetSubmitting] = useState(false);
   const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
-  const [search, setSearch] = useState('');
+  const [searchParams] = useSearchParams();
+  const [search, setSearch] = useState(() => searchParams.get('group') ?? '');
   const [page, setPage] = useState(1);
   const [pageSize, setPageSize] = useState(20);
   const [modeFilter, setModeFilter] = useState<string>('ALL');
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index d985c0eaf..592c9975d 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -51,6 +51,7 @@ import { formatDateTime } from '../../utils/format';
 import {
   createInstance,
   deleteInstance,
+  deleteInstancesBatch,
   importCloudInstances,
   listInstances,
   updateInstance,
@@ -127,6 +128,7 @@ const InstancePage = () => {
   const editInstanceType = Form.useWatch<Instance['type'] | undefined>('type', 
editForm);
   const [submitting, setSubmitting] = useState(false);
   const [importing, setImporting] = useState(false);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
   const requestIdRef = useRef(0);
   const mutationInFlightRef = useRef(false);
   const listQueryRef = useRef<InstanceQuery>({});
@@ -406,11 +408,45 @@ const InstancePage = () => {
       await deleteInstance(instance.name);
       await loadInstances();
       message.success('已删除');
-    } catch {
-      message.error('删除实例失败,请稍后重试');
+    } catch (error) {
+      message.error(describeApiError(error, '删除实例失败,请稍后重试'));
     }
   };
 
+  const handleBatchDelete = () => {
+    const names = selectedRowKeys.map(String);
+    if (names.length === 0) return;
+    const selected = instances.filter((instance) => 
names.includes(instance.name));
+    const hasCloud = selected.some(
+      (instance) => instance.vendor === 'ALIYUN' || instance.vendor === 
'TENCENT',
+    );
+    Modal.confirm({
+      title: `确认删除选中的 ${names.length} 个实例?`,
+      content: hasCloud
+        ? '云厂商实例仅从 Studio 移除记录,不会释放云上的 RocketMQ 实例;仍有 Topic/Group 的开源实例无法删除。'
+        : '仍有 Topic/Group 的开源实例无法删除。',
+      okText: '删除',
+      okButtonProps: { danger: true },
+      onOk: async () => {
+        try {
+          const result = await deleteInstancesBatch(names);
+          await loadInstances();
+          setSelectedRowKeys([]);
+          const summary = `已删除 ${result.deleted} 个`;
+          if (result.failed.length > 0) {
+            message.warning(
+              `${summary},${result.failed.length} 
个未能删除:${result.failed.join(';')}`,
+            );
+          } else {
+            message.success(summary);
+          }
+        } catch (error) {
+          message.error(describeApiError(error, '批量删除失败,请稍后重试'));
+        }
+      },
+    });
+  };
+
   const columns: ColumnsType<Instance> = [
     {
       title: '地域',
@@ -436,7 +472,11 @@ const InstancePage = () => {
       onHeaderCell: () => ({ style: { textAlign: 'left' } }),
       sorter: (a, b) => a.name.localeCompare(b.name),
       render: (text: string) => (
-        <Text strong style={{ fontSize: 14 }}>
+        <Text
+          strong
+          style={{ fontSize: 14, cursor: 'pointer' }}
+          onClick={() => 
navigate(`/instance/${encodeURIComponent(text)}/topic`)}
+        >
           {text}
         </Text>
       ),
@@ -623,13 +663,23 @@ const InstancePage = () => {
             ]}
           />
         </Space>
-        <Button
-          type="primary"
-          icon={<Plus size={14} weight="bold" />}
-          onClick={() => setAddModalOpen(true)}
-        >
-          添加实例
-        </Button>
+        <Space size={12}>
+          <Button
+            danger
+            icon={<DeleteOutlined />}
+            disabled={selectedRowKeys.length === 0}
+            onClick={handleBatchDelete}
+          >
+            删除
+          </Button>
+          <Button
+            type="primary"
+            icon={<Plus size={14} weight="bold" />}
+            onClick={() => setAddModalOpen(true)}
+          >
+            添加实例
+          </Button>
+        </Space>
       </Flex>
 
       {/* Table */}
@@ -639,14 +689,14 @@ const InstancePage = () => {
           columns={columns}
           dataSource={instances}
           loading={loading}
-          rowKey="id"
+          rowKey="name"
+          rowSelection={{
+            selectedRowKeys,
+            onChange: (keys) => setSelectedRowKeys(keys),
+          }}
           pagination={false}
           size="small"
           tableLayout="fixed"
-          onRow={(record) => ({
-            style: { cursor: 'pointer' },
-            onClick: () => 
navigate(`/instance/${encodeURIComponent(record.name)}/topic`),
-          })}
         />
       </Card>
 
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 38db6ed91..51ea45fc7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -16,6 +16,7 @@
  */
 
 import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
 import {
   Alert,
   Table,
@@ -273,6 +274,7 @@ const formatDateTime = (iso?: string): string => {
 // ═══════════════════════════════════════════════════════════════════
 const TopicPage = () => {
   const { t } = useLang();
+  const navigate = useNavigate();
   const {
     selectedInstanceId,
     selectedInstance,
@@ -666,7 +668,25 @@ const TopicPage = () => {
 
   // ─── Consumer table columns ───────────────────────────────────
   const consumerColumns: TableColumnsType<ConsumerGroupInfo> = [
-    { title: '消费者组', dataIndex: 'group', key: 'group' },
+    {
+      title: '消费者组',
+      dataIndex: 'group',
+      key: 'group',
+      render: (group: string) =>
+        selectedInstanceId ? (
+          <Typography.Link
+            onClick={() =>
+              navigate(
+                
`/instance/${encodeURIComponent(selectedInstanceId)}/consumer?group=${encodeURIComponent(group)}`,
+              )
+            }
+          >
+            {group}
+          </Typography.Link>
+        ) : (
+          group
+        ),
+    },
     {
       title: '消费模式',
       dataIndex: 'messageModel',
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 4d9e8e12e..751f68b8a 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -115,6 +115,21 @@ export async function importCloudInstances(data: {
   return instanceApi.importCloudInstances(data);
 }
 
+export async function deleteInstancesBatch(ids: string[]): 
Promise<instanceApi.BatchDeleteResult> {
+  if (isMockMode()) {
+    const known = new Set(mockInstances.map((instance) => instance.name));
+    const failed = ids
+      .filter((id) => !known.has(id))
+      .map((id) => `${id}: Instance not found: ${id}`);
+    for (const id of ids) {
+      const idx = mockInstances.findIndex((instance) => instance.name === id);
+      if (idx >= 0) mockInstances.splice(idx, 1);
+    }
+    return { deleted: ids.length - failed.length, failed };
+  }
+  return instanceApi.deleteInstancesBatch(ids);
+}
+
 export async function updateInstance(data: UpdateInstanceRequest): 
Promise<Instance> {
   if (isMockMode()) {
     const { instanceId, ...changes } = data;

Reply via email to