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 7b7ce9bf feat: add vendor tabs and cloud instance flows to the 
frontend (#1149)
7b7ce9bf is described below

commit 7b7ce9bf7fd738b40d90f03abdb79cb6bb46f845
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 6 20:48:23 2026 +0800

    feat: add vendor tabs and cloud instance flows to the frontend (#1149)
---
 web/src/api/aliyunCatalog.test.ts                  |  70 +++++
 web/src/api/aliyunCatalog.ts                       |  35 +++
 web/src/api/cloudCredential.test.ts                |  56 ++++
 web/src/api/cloudCredential.ts                     |  22 ++
 web/src/api/instance.ts                            |  16 +-
 web/src/api/metadata.ts                            |  26 +-
 web/src/assets/logos/alibabacloud.svg              |   1 +
 web/src/assets/logos/apache-feather.svg            |  29 ++
 web/src/assets/logos/tencentcloud.svg              |   1 +
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  23 +-
 .../pages/instance/__tests__/InstancePage.test.tsx |  17 ++
 .../pages/instance/__tests__/TopicPage.test.tsx    |  19 +-
 web/src/pages/instance/consumer.tsx                | 106 ++++---
 web/src/pages/instance/index.tsx                   | 309 +++++++++++++++++----
 web/src/pages/instance/topic.tsx                   | 210 ++++++++------
 web/src/pages/instance/vendorOptions.ts            |  52 ++++
 web/src/services/consumerService.ts                |  18 +-
 web/src/services/instanceService.ts                |   4 +
 web/src/services/topicService.ts                   |  15 +-
 web/src/utils/format.ts                            |   6 +-
 20 files changed, 818 insertions(+), 217 deletions(-)

diff --git a/web/src/api/aliyunCatalog.test.ts 
b/web/src/api/aliyunCatalog.test.ts
new file mode 100644
index 00000000..4272d134
--- /dev/null
+++ b/web/src/api/aliyunCatalog.test.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { listAliyunInstances, listAliyunRegions } from './aliyunCatalog';
+
+const mock = new MockAdapter(client);
+
+describe('aliyunCatalog API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+  });
+
+  afterEach(() => {
+    mock.reset();
+    vi.unstubAllGlobals();
+  });
+
+  it('lists regions for a credential', async () => {
+    mock.onGet('/cloud/aliyun/regions').reply(200, {
+      code: 200,
+      data: [{ regionId: 'cn-hangzhou', regionName: '华东1(杭州)' }],
+    });
+
+    const regions = await listAliyunRegions('cred-1');
+
+    expect(regions[0].regionId).toBe('cn-hangzhou');
+  });
+
+  it('lists cloud instances with credential and region params', async () => {
+    mock.onGet('/cloud/aliyun/instances').reply((config) => {
+      expect(config.params).toMatchObject({ credentialId: 'cred-1', regionId: 
'cn-hangzhou' });
+      return [
+        200,
+        {
+          code: 200,
+          data: [
+            {
+              instanceId: 'rmq-cn-xxx',
+              instanceName: 'prod-mq',
+              status: 'RUNNING',
+              regionId: 'cn-hangzhou',
+            },
+          ],
+        },
+      ];
+    });
+
+    const instances = await listAliyunInstances('cred-1', 'cn-hangzhou');
+
+    expect(instances[0].instanceId).toBe('rmq-cn-xxx');
+  });
+});
diff --git a/web/src/api/aliyunCatalog.ts b/web/src/api/aliyunCatalog.ts
new file mode 100644
index 00000000..fac4679e
--- /dev/null
+++ b/web/src/api/aliyunCatalog.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+
+export interface CloudRegion {
+  regionId: string;
+  regionName: string;
+}
+
+export interface CloudInstanceOption {
+  instanceId: string;
+  instanceName: string;
+  status: string;
+  regionId: string;
+  topicCount?: number;
+  groupCount?: number;
+  remark?: string;
+}
+
+export async function listAliyunRegions(credentialId: string) {
+  const res = await client.get<{ data: CloudRegion[] 
}>('/cloud/aliyun/regions', {
+    params: { credentialId },
+  });
+  return res.data.data;
+}
+
+export async function listAliyunInstances(credentialId: string, regionId: 
string, search?: string) {
+  const res = await client.get<{ data: CloudInstanceOption[] 
}>('/cloud/aliyun/instances', {
+    params: { credentialId, regionId, ...(search ? { search } : {}) },
+  });
+  return res.data.data;
+}
diff --git a/web/src/api/cloudCredential.test.ts 
b/web/src/api/cloudCredential.test.ts
new file mode 100644
index 00000000..77502914
--- /dev/null
+++ b/web/src/api/cloudCredential.test.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { listCloudCredentials } from './cloudCredential';
+
+const mock = new MockAdapter(client);
+
+describe('cloudCredential API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+  });
+
+  afterEach(() => {
+    mock.reset();
+    vi.unstubAllGlobals();
+  });
+
+  it('returns masked credentials from the backend', async () => {
+    mock.onGet('/cloud-credentials').reply(200, {
+      code: 200,
+      data: [
+        {
+          id: 'cred-1',
+          name: 'aliyun-test',
+          vendor: 'ALIYUN',
+          accessKey: 'LTAI****0001',
+          createdAt: '2026-08-06T00:00:00Z',
+        },
+      ],
+    });
+
+    const credentials = await listCloudCredentials();
+
+    expect(credentials).toHaveLength(1);
+    expect(credentials[0].vendor).toBe('ALIYUN');
+    expect(credentials[0].secretKey).toBeUndefined();
+  });
+});
diff --git a/web/src/api/cloudCredential.ts b/web/src/api/cloudCredential.ts
new file mode 100644
index 00000000..ef0524b5
--- /dev/null
+++ b/web/src/api/cloudCredential.ts
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+import type { InstanceVendor } from './instance';
+
+export interface CloudCredential {
+  id: string;
+  name: string;
+  vendor: InstanceVendor;
+  accessKey: string;
+  secretKey?: string;
+  remark?: string;
+  createdAt: string;
+}
+
+export async function listCloudCredentials() {
+  const res = await client.get<{ data: CloudCredential[] 
}>('/cloud-credentials');
+  return res.data.data;
+}
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 98b0609d..76344f6e 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -18,12 +18,18 @@
 import client from './client';
 
 // ─── Types ──────────────────────────────────────────────────────
+export type InstanceVendor = 'APACHE' | 'ALIYUN' | 'TENCENT';
+
 export interface Instance {
   id: string;
   name: string;
   remark: string;
   type: 'PROXY' | 'DIRECT';
   endpoint: string;
+  vendor?: InstanceVendor;
+  cloudInstanceId?: string;
+  credentialId?: string;
+  regionId?: string;
   topicCount: number;
   consumerGroupCount: number;
   createdAt: string;
@@ -31,10 +37,14 @@ export interface Instance {
 }
 
 export interface CreateInstanceRequest {
-  name: string;
-  type: 'PROXY' | 'DIRECT';
-  endpoint: string;
+  name?: string;
+  type?: 'PROXY' | 'DIRECT';
+  endpoint?: string;
   remark?: string;
+  vendor?: InstanceVendor;
+  cloudInstanceId?: string;
+  credentialId?: string;
+  regionId?: string;
 }
 
 export interface UpdateInstanceRequest {
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index f3897b18..7d63cccc 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -96,6 +96,7 @@ export interface SubscriptionEntry {
 }
 
 export interface ConsumerGroupQuery {
+  instanceId?: string;
   clusterId?: string;
   search?: string;
 }
@@ -122,20 +123,22 @@ export async function updateTopic(data: Partial<Topic>) {
   return res.data.data;
 }
 
-export async function deleteTopic(name: string) {
-  await client.post('/topics/delete', { name });
+export async function deleteTopic(name: string, instanceId?: string) {
+  await client.post('/topics/delete', { name, ...(instanceId ? { instanceId } 
: {}) });
 }
 
-export async function getTopicRoutes(name: string) {
+export async function getTopicRoutes(name: string, instanceId?: string) {
   const res = await client.get<{ data: BrokerRoute[] }>(
     `/topics/${encodeURIComponent(name)}/routes`,
+    { params: instanceId ? { instanceId } : {} },
   );
   return res.data.data;
 }
 
-export async function getTopicConsumers(name: string) {
+export async function getTopicConsumers(name: string, instanceId?: string) {
   const res = await client.get<{ data: ConsumerGroupInfo[] }>(
     `/topics/${encodeURIComponent(name)}/consumers`,
+    { params: instanceId ? { instanceId } : {} },
   );
   return res.data.data;
 }
@@ -146,6 +149,7 @@ export interface SendTopicMessageRequest {
   key?: string;
   body: string;
   properties?: Record<string, string>;
+  instanceId?: string;
 }
 
 export interface SendTopicMessageResult {
@@ -165,23 +169,26 @@ export async function listConsumerGroups(params?: 
ConsumerGroupQuery) {
   return res.data.data;
 }
 
-export async function getConsumerGroup(name: string) {
+export async function getConsumerGroup(name: string, instanceId?: string) {
   const res = await client.get<{ data: ConsumerGroupDetail }>(
     `/groups/${encodeURIComponent(name)}`,
+    { params: instanceId ? { instanceId } : {} },
   );
   return res.data.data;
 }
 
-export async function getConsumerProgress(name: string) {
+export async function getConsumerProgress(name: string, instanceId?: string) {
   const res = await client.get<{ data: QueueProgress[] }>(
     `/groups/${encodeURIComponent(name)}/progress`,
+    { params: instanceId ? { instanceId } : {} },
   );
   return res.data.data;
 }
 
-export async function getConsumerSubscriptions(name: string) {
+export async function getConsumerSubscriptions(name: string, instanceId?: 
string) {
   const res = await client.get<{ data: SubscriptionEntry[] }>(
     `/groups/${encodeURIComponent(name)}/subscriptions`,
+    { params: instanceId ? { instanceId } : {} },
   );
   return res.data.data;
 }
@@ -191,14 +198,15 @@ export async function createConsumerGroup(data: 
Partial<ConsumerGroup>) {
   return res.data.data;
 }
 
-export async function deleteConsumerGroup(name: string) {
-  await client.post('/groups/delete', { name });
+export async function deleteConsumerGroup(name: string, instanceId?: string) {
+  await client.post('/groups/delete', { name, ...(instanceId ? { instanceId } 
: {}) });
 }
 
 export interface ResetConsumerOffsetRequest {
   name: string;
   timestamp: number;
   topic?: string;
+  instanceId?: string;
 }
 
 export async function resetConsumerOffset(data: ResetConsumerOffsetRequest) {
diff --git a/web/src/assets/logos/alibabacloud.svg 
b/web/src/assets/logos/alibabacloud.svg
new file mode 100644
index 00000000..c1d8e4ff
--- /dev/null
+++ b/web/src/assets/logos/alibabacloud.svg
@@ -0,0 +1 @@
+<svg fill="#FF6A00" role="img" viewBox="0 0 24 24" 
xmlns="http://www.w3.org/2000/svg";><title>Alibaba Cloud</title><path d="M3.996 
4.517h5.291L8.01 6.324 4.153 7.506a1.668 1.668 0 0 0-1.165 1.601v5.786a1.668 
1.668 0 0 0 1.165 1.6l3.857 1.183 1.277 1.807H3.996A3.996 3.996 0 0 1 0 
15.487V8.513a3.996 3.996 0 0 1 3.996-3.996m16.008 0h-5.291l1.277 1.807 3.857 
1.182c.715.227 1.17.889 1.165 1.601v5.786a1.668 1.668 0 0 1-1.165 1.6l-3.857 
1.183-1.277 1.807h5.291A3.996 3.996 0 0 0 24 15.487V8.513a3 [...]
\ No newline at end of file
diff --git a/web/src/assets/logos/apache-feather.svg 
b/web/src/assets/logos/apache-feather.svg
new file mode 100644
index 00000000..261459fa
--- /dev/null
+++ b/web/src/assets/logos/apache-feather.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 650 1000">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #7c297d;
+      }
+
+      .cls-2 {
+        fill: #f79a23;
+      }
+
+      .cls-3 {
+        fill: #dd552c;
+      }
+
+      .cls-4 {
+        fill: #d22128;
+      }
+    </style>
+  </defs>
+  <path class="cls-3" 
d="M276.7092915,398.1515795c25.5279479-63.1242453,54.0110775-126.1246793,84.5729347-181.9636035-45.6212286-33.8852148-89.4276433-106.9534674-107.4259055-139.3268803-6.4642564,7.3439687-10.6608099,15.8022396-12.563595,22.6835448-16.9556402,61.2214602,43.4023987,135.1498759-5.21311,108.1394499-40.5058645-22.5076023-131.7157397-71.797557-166.5067324-22.8073561,38.9647388,50.0654049,140.8777805,176.0271745,207.1364082,213.2748452Z"/>
+  <path class="cls-2" 
d="M361.2822261,216.187976c29.6137228-54.1055651,61.1725873-101.4927347,93.8913687-135.6320886,0,0-32.6340684,47.2372927-79.2457879,141.6662634,28.2289905,7.7740502,108.6249208,23.7261667,220.6090393-5.2000772,2.7531737-20.350678-10.9279818-42.734469-79.1056856-50.2283145-44.5101845-4.8872906,53.4246026-106.2822795-17.5225659-154.2363748-2.2905102-1.5509002-4.5419221-2.9193416-6.7477192-4.1444224-2.3784814-.8536468-4.8905488-1.6356133-7.5720422-2.3328667-82.8591248-
 [...]
+  <path class="cls-4" 
d="M210.0661969,580.0239535c18.7052902-56.0344158,41.2063761-118.989235,66.6430946-181.872374-66.2586277-37.2476707-168.1716694-163.2094403-207.1364082-213.2748452-6.9562436,9.787614-11.7099483,23.4394457-13.2934304,42.1088958-8.4973692,100.2806866,94.9567981,174.521889,74.3324318,188.0824913-27.2808561,17.9396147-81.5786546-43.0928703-102.978471-4.3138485,31.0180043,39.8477093,94.2008971,111.8472744,182.4327833,169.2696807Z"/>
+  <path class="cls-3" 
d="M496.7155649,363.7515701c-52.3819806-18.6824828,54.7376547-68.786986,89.5221309-121.9411586,4.4506926-6.7965921,9.0512622-15.5806824,10.2991504-24.7883379-111.9841185,28.9262439-192.3800488,12.9741274-220.6090393,5.2000772-24.1301828,48.8924551-51.9942555,110.5048986-81.0540854,184.7917156,30.2653616,12.9415455,153.8421334,60.7457639,328.3900879,60.9933866,29.3465509-76.4372249-76.8347245-86.5311091-126.5482445-104.255683Z"/>
+  <path class="cls-4" 
d="M230.3060964,590.1113213c30.7801562,9.5921223,132.7681363,38.244678,241.0835287,33.9308295,14.5510932-39.3980786-39.8509675-43.2427472-44.282111-74.84071-3.430878-24.4494858,143.1682907,20.5461697,190.3371613-68.3569045,2.3849978-4.4963073,4.2454264-8.7384756,5.819134-12.8372833-174.5479545-.2476227-298.1247263-48.0518411-328.3900879-60.9933866-21.2369067,54.2880239-43.0830957,115.2162467-64.5676252,183.0974549Z"/>
+  <path class="cls-1" 
d="M230.3060964,590.1113213c-13.8310324,43.6923779-27.4763477,90.3692613-40.7209052,139.6983144-4.6983154,17.4899839-9.3412414,35.3057873-13.9190036,53.5125738,102.8057868,33.9373459,197.4726056.0781966,200.6819264-41.8873386.0260655-.3323358-.0358401-.5799585-.016291-.8992615,2.4469035-44.4482789-64.1733837-19.8098179-62.5964179-46.5335229,1.5834822-26.9191966,116.3077416-.1563933,151.7862131-57.872037,2.7205918-4.4246271,4.4930492-8.3540087,5.8680069-12.0878987-10
 [...]
+  <path class="cls-1" 
d="M27.6334136,410.7542728c-1.4987691,2.7173336-2.8509195,5.8582323-4.0043201,9.6116715-19.9238547,64.7533422,120.9604422,151.7405984,101.7924885,170.7032859-17.2782014,17.0859679-39.7955782-21.9602257-67.5619052-5.8321668-3.0431529,1.7724574-6.1319206,4.0075783-9.3021431,7.2592556-31.4024712,32.1714049-.4919873,124.8539837,88.6033203,174.3263973-20.7905342,69.8100589-41.489839,147.8047004-61.7525458,229.3703222,7.3504851-2.573973,16.1476081-5.1544625,18.3371143-12.
 [...]
+</svg>
\ No newline at end of file
diff --git a/web/src/assets/logos/tencentcloud.svg 
b/web/src/assets/logos/tencentcloud.svg
new file mode 100644
index 00000000..53ce4ad1
--- /dev/null
+++ b/web/src/assets/logos/tencentcloud.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="22" viewBox="-1 0 
24 22"><path fill="#0052d9" d="M94.753 5.145c1.529 0 3.031.561 4.067 
1.494v1.409a.08.08 0 0 1-.137.056c-.918-.954-2.454-1.659-3.928-1.659-2.534 
0-4.11 1.784-4.11 4.517.001 2.733 1.653 4.5 4.21 4.5 1.722 0 3.08-.832 
3.972-1.817.048-.055.138-.02.138.053l.002.002v1.545c-1.039.96-2.557 1.535-4.112 
1.535-3.26 0-5.45-2.338-5.45-5.818 0-1.71.502-3.166 1.45-4.209.958-1.052 
2.37-1.608 3.898-1.608M59.316 8.196c1.18 0 2.32 [...]
\ No newline at end of file
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index fd191da3..c7758803 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -37,7 +37,19 @@ vi.mock('../../../services/consumerService', () => ({
   resetConsumerOffset: vi.fn(),
 }));
 vi.mock('../../../services/instanceService', () => ({
-  listInstances: vi.fn().mockResolvedValue([]),
+  listInstances: vi.fn().mockResolvedValue([
+    {
+      id: 'instance-1',
+      name: 'instance-1',
+      remark: '',
+      type: 'PROXY',
+      endpoint: '10.0.0.1:8080',
+      topicCount: 0,
+      consumerGroupCount: 0,
+      createdAt: '2026-01-01T00:00:00Z',
+      updatedAt: '2026-01-01T00:00:00Z',
+    },
+  ]),
 }));
 
 beforeAll(() => {
@@ -68,6 +80,7 @@ const group: ConsumerGroup = {
   name: 'remote-cg',
   namespace: 'remote-ns',
   clusterId: 'cluster-a',
+  instanceId: 'instance-1',
   subscriptionMode: 'Push',
   consumeType: 'CLUSTERING',
   onlineInstances: 1,
@@ -189,10 +202,13 @@ describe('Consumer page', () => {
     await user.click(await screen.findByRole('button', { name: /详情/ }));
 
     await waitFor(() =>
-      
expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith('remote-cg'),
+      expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith(
+        'remote-cg',
+        'instance-1',
+      ),
     );
     await waitFor(() =>
-      
expect(consumerService.getConsumerProgress).toHaveBeenCalledWith('remote-cg'),
+      
expect(consumerService.getConsumerProgress).toHaveBeenCalledWith('remote-cg', 
'instance-1'),
     );
     expect(consumerService.getConsumerGroup).not.toHaveBeenCalled();
     await waitFor(() => 
expect(screen.getAllByText('remote-topic').length).toBeGreaterThan(0));
@@ -329,6 +345,7 @@ describe('Consumer page', () => {
       retryMaxTimes: 16,
       subscriptionDataType: 'NORMAL',
       subscribedTopics: [],
+      instanceId: 'instance-1',
     });
     expect(await screen.findByText('已导入 1 个 Group,1 个失败')).toBeInTheDocument();
     expect(screen.getByText('broker rejected group')).toBeInTheDocument();
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 48d81511..6a628f9f 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -188,4 +188,21 @@ describe('InstancePage', () => {
     );
     expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type: 
'DIRECT' });
   });
+
+  it('shows vendor tabs in the add instance modal and switches description', 
async () => {
+    const user = userEvent.setup();
+    renderPage();
+
+    expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /添加实例/ }));
+    const dialog = await screen.findByRole('dialog');
+
+    expect(within(dialog).getByRole('tab', { name: /开源版/ 
})).toBeInTheDocument();
+    expect(within(dialog).getByRole('tab', { name: /Aliyun 版/ 
})).toBeInTheDocument();
+    expect(within(dialog).getByRole('tab', { name: /Tencent 版/ 
})).toBeInTheDocument();
+    expect(within(dialog).getByText(/自建 Apache RocketMQ 
开源集群/)).toBeInTheDocument();
+
+    await user.click(within(dialog).getByRole('tab', { name: /Aliyun 版/ }));
+    expect(within(dialog).getByText(/云凭据与云上实例完成接入/)).toBeInTheDocument();
+  });
 });
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 05ae8715..9fb9cc26 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -73,6 +73,7 @@ const buildTopics = (count: number): Topic[] =>
       namespace: 'default',
       type: 'NORMAL',
       clusterId: 'rmq-cn-v5-prod-01',
+      instanceId: 'instance-proxy-1',
       writeQueues: 8,
       readQueues: 8,
       perm: 'RW',
@@ -122,7 +123,19 @@ describe('TopicPage', () => {
     }));
     topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
     topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
-    instanceServiceMocks.listInstances.mockResolvedValue([]);
+    instanceServiceMocks.listInstances.mockResolvedValue([
+      {
+        id: 'instance-proxy-1',
+        name: 'instance-proxy-1',
+        remark: '',
+        type: 'PROXY',
+        endpoint: '10.0.2.21:8080',
+        topicCount: 1,
+        consumerGroupCount: 0,
+        createdAt: '2026-01-01T00:00:00Z',
+        updatedAt: '2026-01-01T00:00:00Z',
+      },
+    ]);
   });
 
   afterEach(() => {
@@ -186,7 +199,9 @@ describe('TopicPage', () => {
     
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
 
     await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
-    await waitFor(() => 
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-21'));
+    await waitFor(() =>
+      
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-21', 
'instance-proxy-1'),
+    );
 
     const closeButton = document.querySelector('.ant-modal-close');
     expect(closeButton).not.toBeNull();
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 742e0d45..3bc792eb 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -130,7 +130,7 @@ const GROUP_EXPORT_COLUMNS: Array<{ header: string; value: 
(group: ConsumerGroup
   { header: 'Subscription Data Type', value: (group) => 
group.subscriptionDataType },
   { header: 'Delivery Order Type', value: (group) => group.deliveryOrderType },
   { header: 'Retry Max Times', value: (group) => group.retryMaxTimes },
-  { header: 'Subscribed Topics', value: (group) => 
group.subscribedTopics.join(';') },
+  { header: 'Subscribed Topics', value: (group) => (group.subscribedTopics ?? 
[]).join(';') },
   { header: 'Created At', value: (group) => group.createdAt },
   { header: 'Updated At', value: (group) => group.updatedAt },
 ];
@@ -181,7 +181,10 @@ const isInconsistentSubscription = (subscription: 
SubscriptionEntry): boolean =>
    ═══════════════════════════════════════════ */
 const ConsumerPage = () => {
   const { t } = useLang();
-  const { selectedInstanceId, selectInstance, instanceOptions } = 
useInstanceFilter();
+  const { selectedInstanceId, selectedInstance, selectInstance, 
instanceOptions } =
+    useInstanceFilter();
+  const isCloudInstance =
+    selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor === 
'TENCENT';
   const [groups, setGroups] = useState<ConsumerGroup[]>([]);
   const [loading, setLoading] = useState(true);
   const [submitting, setSubmitting] = useState(false);
@@ -216,25 +219,30 @@ const ConsumerPage = () => {
   const [importErrors, setImportErrors] = useState<string[]>([]);
   const [importing, setImporting] = useState(false);
 
-  useEffect(() => {
-    let cancelled = false;
-
-    const fetchGroups = async () => {
-      try {
-        const nextGroups = await listConsumerGroups();
-        if (!cancelled) setGroups(nextGroups);
-      } catch {
-        if (!cancelled) message.error(t('consumer.fetchListFailed'));
-      } finally {
-        if (!cancelled) setLoading(false);
-      }
-    };
+  const groupRequestIdRef = useRef(0);
 
-    void fetchGroups();
+  useEffect(() => {
+    if (!selectedInstanceId) {
+      return;
+    }
+    const requestId = ++groupRequestIdRef.current;
+    const timer = window.setTimeout(() => {
+      setLoading(true);
+      void listConsumerGroups({ instanceId: selectedInstanceId })
+        .then((nextGroups) => {
+          if (requestId === groupRequestIdRef.current) setGroups(nextGroups);
+        })
+        .catch(() => {
+          if (requestId === groupRequestIdRef.current) 
message.error(t('consumer.fetchListFailed'));
+        })
+        .finally(() => {
+          if (requestId === groupRequestIdRef.current) setLoading(false);
+        });
+    }, 0);
     return () => {
-      cancelled = true;
+      window.clearTimeout(timer);
     };
-  }, [t]);
+  }, [t, selectedInstanceId]);
 
   const loadSubscriptions = useCallback(
     async (groupName: string, force = false) => {
@@ -242,7 +250,10 @@ const ConsumerPage = () => {
       setSubscriptionLoadingByGroup((prev) => ({ ...prev, [groupName]: true 
}));
       setSubscriptionErrorByGroup((prev) => ({ ...prev, [groupName]: false }));
       try {
-        const subscriptions = await getConsumerSubscriptions(groupName);
+        const subscriptions = await getConsumerSubscriptions(
+          groupName,
+          selectedInstanceId || undefined,
+        );
         setSubscriptionsByGroup((prev) => ({ ...prev, [groupName]: 
subscriptions }));
       } catch {
         setSubscriptionErrorByGroup((prev) => ({ ...prev, [groupName]: true 
}));
@@ -251,26 +262,26 @@ const ConsumerPage = () => {
         setSubscriptionLoadingByGroup((prev) => ({ ...prev, [groupName]: false 
}));
       }
     },
-    [subscriptionsByGroup, t],
+    [subscriptionsByGroup, t, selectedInstanceId],
   );
 
   const loadProgress = useCallback(
     async (groupName: string) => {
       if (progressByGroup[groupName]) return;
       try {
-        const progress = await getConsumerProgress(groupName);
+        const progress = await getConsumerProgress(groupName, 
selectedInstanceId || undefined);
         setProgressByGroup((prev) => ({ ...prev, [groupName]: progress }));
       } catch {
         message.error(t('consumer.fetchProgressFailed', { name: groupName }));
       }
     },
-    [progressByGroup, t],
+    [progressByGroup, t, selectedInstanceId],
   );
 
   /* ─── Filtered & sorted data ─── */
   const filtered = useMemo(() => {
     let data = groups.filter(
-      (g) => g.name.includes(search) || g.subscribedTopics.some((t) => 
t.includes(search)),
+      (g) => g.name.includes(search) || (g.subscribedTopics ?? []).some((t) => 
t.includes(search)),
     );
 
     if (selectedInstanceId) {
@@ -421,7 +432,7 @@ const ConsumerPage = () => {
       dataIndex: 'subscriptionDataType',
       key: 'subscriptionDataType',
       width: 110,
-      sorter: (a, b) => 
a.subscriptionDataType.localeCompare(b.subscriptionDataType),
+      sorter: (a, b) => (a.subscriptionDataType ?? 
'').localeCompare(b.subscriptionDataType ?? ''),
       render: (type: string) => {
         const config = TOPIC_TYPE_MAP[type] || { labelKey: type, color: 
'default' };
         return <Tag color={config.color}>{t(config.labelKey)}</Tag>;
@@ -432,7 +443,7 @@ const ConsumerPage = () => {
       dataIndex: 'subscriptionMode',
       key: 'subscriptionMode',
       width: 90,
-      sorter: (a, b) => a.subscriptionMode.localeCompare(b.subscriptionMode),
+      sorter: (a, b) => (a.subscriptionMode ?? 
'').localeCompare(b.subscriptionMode ?? ''),
       render: (mode: string) => <Tag color={mode === 'Push' ? 'blue' : 
'green'}>{mode}</Tag>,
     },
     {
@@ -441,30 +452,30 @@ const ConsumerPage = () => {
       key: 'onlineInstances',
       width: 130,
       align: 'center',
-      sorter: (a, b) => a.onlineInstances - b.onlineInstances,
+      sorter: (a, b) => (a.onlineInstances ?? 0) - (b.onlineInstances ?? 0),
     },
     {
       title: '总堆积量',
       dataIndex: 'totalLag',
       key: 'totalLag',
       width: 120,
-      sorter: (a, b) => a.totalLag - b.totalLag,
-      render: (lag: number) => lag.toLocaleString(),
+      sorter: (a, b) => (a.totalLag ?? 0) - (b.totalLag ?? 0),
+      render: (lag: number) => (lag ?? 0).toLocaleString(),
     },
     {
       title: '消费延迟',
       dataIndex: 'delaySeconds',
       key: 'delaySeconds',
       width: 160,
-      sorter: (a, b) => a.delaySeconds - b.delaySeconds,
-      render: (seconds: number) => formatDelay(seconds),
+      sorter: (a, b) => (a.delaySeconds ?? 0) - (b.delaySeconds ?? 0),
+      render: (seconds: number) => formatDelay(seconds ?? 0),
     },
     {
       title: '创建时间',
       dataIndex: 'createdAt',
       key: 'createdAt',
       width: 170,
-      sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+      sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
       render: (d: string) => (
         <Text type="secondary" style={{ fontSize: 13 }}>
           {formatDateTime(d)}
@@ -476,7 +487,7 @@ const ConsumerPage = () => {
       dataIndex: 'updatedAt',
       key: 'updatedAt',
       width: 170,
-      sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
+      sorter: (a, b) => (a.updatedAt ?? '').localeCompare(b.updatedAt ?? ''),
       render: (d: string) => (
         <Text type="secondary" style={{ fontSize: 13 }}>
           {formatDateTime(d)}
@@ -526,7 +537,7 @@ const ConsumerPage = () => {
                 okButtonProps: { danger: true },
                 cancelText: '取消',
                 onOk: async () => {
-                  await deleteConsumerGroup(record.name);
+                  await deleteConsumerGroup(record.name, selectedInstanceId || 
undefined);
                   setGroups((prev) => prev.filter((group) => group.name !== 
record.name));
                   setSelectedRowKeys((prev) => prev.filter((key) => key !== 
record.name));
                   message.success(`消费组 ${record.name} 已删除`);
@@ -1264,19 +1275,23 @@ const ConsumerPage = () => {
             <Input placeholder="例:cg-order-notify" />
           </Form.Item>
 
-          <Form.Item label="订阅模式" name="subscriptionMode">
-            <Radio.Group>
-              <Radio.Button value="Push">Push</Radio.Button>
-              <Radio.Button value="Pop">Pop</Radio.Button>
-            </Radio.Group>
-          </Form.Item>
+          {!isCloudInstance && (
+            <Form.Item label="订阅模式" name="subscriptionMode">
+              <Radio.Group>
+                <Radio.Button value="Push">Push</Radio.Button>
+                <Radio.Button value="Pop">Pop</Radio.Button>
+              </Radio.Group>
+            </Form.Item>
+          )}
 
-          <Form.Item label="消费类型" name="consumeType">
-            <Radio.Group>
-              <Radio.Button value="CLUSTERING">集群消费</Radio.Button>
-              <Radio.Button value="BROADCASTING">广播消费</Radio.Button>
-            </Radio.Group>
-          </Form.Item>
+          {!isCloudInstance && (
+            <Form.Item label="消费类型" name="consumeType">
+              <Radio.Group>
+                <Radio.Button value="CLUSTERING">集群消费</Radio.Button>
+                <Radio.Button value="BROADCASTING">广播消费</Radio.Button>
+              </Radio.Group>
+            </Form.Item>
+          )}
 
           <Form.Item label="最大重试次数" name="retryMaxTimes">
             <InputNumber min={0} max={128} style={{ width: '100%' }} />
@@ -1393,6 +1408,7 @@ const ConsumerPage = () => {
               await resetConsumerOffset({
                 name: resetGroup.name,
                 timestamp: resetTime.valueOf(),
+                instanceId: selectedInstanceId || undefined,
               });
               message.success(
                 `${resetGroup.name} 消费位点已重置到 ${resetTime.format('YYYY-MM-DD 
HH:mm:ss')}`,
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index aec233b2..8c8f6664 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -28,6 +28,7 @@ import {
   Modal,
   Form,
   Flex,
+  Tabs,
   Typography,
   Alert,
   message,
@@ -37,6 +38,13 @@ import { Plus, MagnifyingGlass } from 
'@phosphor-icons/react';
 import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import type { Instance, InstanceQuery } from '../../api/instance';
+import { listCloudCredentials, type CloudCredential } from 
'../../api/cloudCredential';
+import {
+  listAliyunInstances,
+  listAliyunRegions,
+  type CloudInstanceOption,
+  type CloudRegion,
+} from '../../api/aliyunCatalog';
 import { formatDateTime } from '../../utils/format';
 import {
   createInstance,
@@ -44,9 +52,12 @@ import {
   listInstances,
   updateInstance,
 } from '../../services/instanceService';
+import { DEFAULT_VENDOR, VENDOR_OPTIONS, type InstanceVendor } from 
'./vendorOptions';
 
 const { Text } = Typography;
 
+const DEFAULT_ALIYUN_REGION_ID = 'cn-hangzhou';
+
 /* ─── Helpers ─── */
 const typeLabel: Record<string, { text: string; color: string }> = {
   PROXY: { text: 'Proxy 模式', color: 'blue' },
@@ -67,8 +78,17 @@ const InstancePage = () => {
   const [debouncedSearch, setDebouncedSearch] = useState('');
   const [typeFilter, setTypeFilter] = useState<InstanceTypeFilter>('ALL');
   const [addModalOpen, setAddModalOpen] = useState(false);
+  const [vendor, setVendor] = useState<InstanceVendor>(DEFAULT_VENDOR);
   const [addForm] = Form.useForm();
   const addInstanceType = Form.useWatch<'PROXY' | 'DIRECT' | 
undefined>('type', addForm);
+  const addCredentialId = Form.useWatch<string | undefined>('credentialId', 
addForm);
+  const addRegionId = Form.useWatch<string | undefined>('regionId', addForm);
+  const [credentials, setCredentials] = useState<CloudCredential[]>([]);
+  const [credentialsLoading, setCredentialsLoading] = useState(false);
+  const [regions, setRegions] = useState<CloudRegion[]>([]);
+  const [regionsLoading, setRegionsLoading] = useState(false);
+  const [cloudInstances, setCloudInstances] = 
useState<CloudInstanceOption[]>([]);
+  const [cloudInstancesLoading, setCloudInstancesLoading] = useState(false);
   const [editModalOpen, setEditModalOpen] = useState(false);
   const [editingInstance, setEditingInstance] = useState<Instance | 
null>(null);
   const [editForm] = Form.useForm();
@@ -113,15 +133,88 @@ const InstancePage = () => {
     };
   }, [loadInstances]);
 
+  useEffect(() => {
+    if (vendor !== 'ALIYUN' || !addModalOpen) {
+      return;
+    }
+    const timer = window.setTimeout(() => {
+      setCredentialsLoading(true);
+      listCloudCredentials()
+        .then((items) => setCredentials(items.filter((item) => item.vendor === 
'ALIYUN')))
+        .catch(() => message.error('云凭据列表加载失败'))
+        .finally(() => setCredentialsLoading(false));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, [vendor, addModalOpen]);
+
+  useEffect(() => {
+    if (vendor !== 'ALIYUN' || !addCredentialId) {
+      return;
+    }
+    const timer = window.setTimeout(() => {
+      setRegionsLoading(true);
+      listAliyunRegions(addCredentialId)
+        .then((items) => {
+          setRegions(items);
+          if (!addForm.getFieldValue('regionId')) {
+            const preferred = items.find((region) => region.regionId === 
DEFAULT_ALIYUN_REGION_ID);
+            if (preferred) {
+              addForm.setFieldsValue({ regionId: preferred.regionId });
+            }
+          }
+        })
+        .catch(() => message.error('云地域列表加载失败'))
+        .finally(() => setRegionsLoading(false));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, [vendor, addCredentialId, addForm]);
+
+  useEffect(() => {
+    if (vendor !== 'ALIYUN' || !addCredentialId || !addRegionId) {
+      return;
+    }
+    const timer = window.setTimeout(() => {
+      setCloudInstancesLoading(true);
+      listAliyunInstances(addCredentialId, addRegionId)
+        .then(setCloudInstances)
+        .catch(() => message.error('云实例列表加载失败'))
+        .finally(() => setCloudInstancesLoading(false));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, [vendor, addCredentialId, addRegionId]);
+
+  const handleCredentialChange = () => {
+    setRegions([]);
+    setCloudInstances([]);
+    addForm.setFieldsValue({ regionId: undefined, cloudInstanceId: undefined 
});
+  };
+
+  const handleRegionChange = () => {
+    setCloudInstances([]);
+    addForm.setFieldsValue({ cloudInstanceId: undefined });
+  };
+
   const handleCreate = async () => {
     try {
       const values = await addForm.validateFields();
       setSubmitting(true);
-      const created = await createInstance(values);
+      const payload =
+        vendor === 'ALIYUN'
+          ? {
+              name: values.name,
+              vendor: 'ALIYUN' as const,
+              credentialId: values.credentialId,
+              cloudInstanceId: values.cloudInstanceId,
+              regionId: values.regionId,
+              remark: values.remark,
+            }
+          : values;
+      const created = await createInstance(payload);
       await loadInstances();
       message.success(`实例「${created.name}」添加成功`);
       setAddModalOpen(false);
       addForm.resetFields();
+      setVendor(DEFAULT_VENDOR);
     } catch {
       message.error('添加实例失败,请稍后重试');
     } finally {
@@ -183,6 +276,24 @@ const InstancePage = () => {
         </Text>
       ),
     },
+    {
+      title: '厂商',
+      dataIndex: 'vendor',
+      key: 'vendor',
+      width: 140,
+      render: (value?: string) => {
+        const option = VENDOR_OPTIONS.find((item) => item.key === (value || 
'APACHE'));
+        if (!option) {
+          return <Text type="secondary">{value || '-'}</Text>;
+        }
+        return (
+          <Space size={6}>
+            <img src={option.logo} alt={option.label} style={{ height: 16 }} />
+            <Text style={{ fontSize: 13 }}>{option.label}</Text>
+          </Space>
+        );
+      },
+    },
     {
       title: '类型',
       dataIndex: 'type',
@@ -343,65 +454,165 @@ const InstancePage = () => {
         onCancel={() => {
           setAddModalOpen(false);
           addForm.resetFields();
+          setVendor(DEFAULT_VENDOR);
+          setRegions([]);
+          setCloudInstances([]);
         }}
         onOk={() => void handleCreate()}
         confirmLoading={submitting}
+        okButtonProps={{ disabled: vendor === 'TENCENT' }}
         okText="连接"
         cancelText="取消"
         width={520}
       >
-        <Form form={addForm} layout="vertical" style={{ marginTop: 16 }}>
-          <Form.Item
-            label="实例名称"
-            name="name"
-            rules={[{ required: true, message: '请输入实例名称' }]}
-          >
-            <Input placeholder="例:rocketmq-production" />
-          </Form.Item>
-          <Form.Item
-            label="接入方式"
-            name="type"
-            rules={[{ required: true, message: '请选择接入方式' }]}
-          >
-            <Select
-              placeholder="选择接入方式"
-              options={[
-                { value: 'PROXY', label: 'Proxy 模式' },
-                { value: 'DIRECT', label: 'Direct 模式' },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item
-            label="接入地址"
-            name="endpoint"
-            rules={[{ required: true, message: '请输入接入地址' }]}
-            extra={
-              addInstanceType === 'DIRECT'
-                ? 'Direct 模式请填写 NameServer SLB 地址(K8s 场景下一般为 NameServer 
Service 地址,如 namesrv.mq.svc:9876)'
-                : addInstanceType === 'PROXY'
-                  ? 'Proxy 模式请填写 Proxy SLB 内网地址(如 proxy.mq.svc:8080)'
-                  : '请先选择接入方式'
-            }
-          >
-            <Input
-              placeholder={
-                addInstanceType === 'DIRECT'
-                  ? '例:namesrv.mq.svc.cluster.local:9876'
-                  : '例:proxy.mq.svc.cluster.local:8080'
-              }
-            />
-          </Form.Item>
+        <Tabs
+          type="card"
+          activeKey={vendor}
+          onChange={(key) => setVendor(key as InstanceVendor)}
+          style={{ marginTop: 8, marginBottom: 4 }}
+          items={VENDOR_OPTIONS.map((option) => ({
+            key: option.key,
+            label: (
+              <span style={{ display: 'inline-flex', alignItems: 'center', 
gap: 8 }}>
+                <img src={option.logo} alt={option.label} style={{ height: 18, 
maxWidth: 80 }} />
+                {option.label}
+              </span>
+            ),
+          }))}
+        />
+        <Text type="secondary" style={{ display: 'block', fontSize: 12, 
marginBottom: 12 }}>
+          {VENDOR_OPTIONS.find((option) => option.key === vendor)?.description}
+        </Text>
+        {vendor === 'ALIYUN' ? (
+          <Form form={addForm} layout="vertical">
+            <Form.Item
+              label="云凭据"
+              name="credentialId"
+              rules={[{ required: true, message: '请选择云凭据' }]}
+              extra="凭据为阿里云账号的 AK/SK,在云凭据管理中录入"
+            >
+              <Select
+                placeholder="选择已录入的 AK/SK 凭据"
+                loading={credentialsLoading}
+                onChange={handleCredentialChange}
+                options={credentials.map((item) => ({
+                  value: item.id,
+                  label: `${item.name}(${item.accessKey})`,
+                }))}
+              />
+            </Form.Item>
+            <Form.Item
+              label="地域"
+              name="regionId"
+              rules={[{ required: true, message: '请选择地域' }]}
+            >
+              <Select
+                placeholder={addCredentialId ? '选择地域' : '请先选择云凭据'}
+                disabled={!addCredentialId}
+                loading={regionsLoading}
+                onChange={handleRegionChange}
+                options={regions.map((region) => ({
+                  value: region.regionId,
+                  label: `${region.regionName}(${region.regionId})`,
+                }))}
+              />
+            </Form.Item>
+            <Form.Item
+              label="云上实例"
+              name="cloudInstanceId"
+              rules={[{ required: true, message: '请选择云上实例' }]}
+              extra="商业版实例来自云端目录,无法手工创建"
+            >
+              <Select
+                showSearch
+                optionFilterProp="label"
+                placeholder={addRegionId ? '选择云上实例' : '请先选择地域'}
+                disabled={!addRegionId}
+                loading={cloudInstancesLoading}
+                options={cloudInstances.map((item) => ({
+                  value: item.instanceId,
+                  label: `${item.instanceName || 
item.instanceId}(${item.instanceId})`,
+                }))}
+                onChange={(value) => {
+                  const selected = cloudInstances.find((item) => 
item.instanceId === value);
+                  if (selected?.instanceName) {
+                    addForm.setFieldsValue({ name: selected.instanceName });
+                  }
+                }}
+              />
+            </Form.Item>
+            <Form.Item
+              label="实例名称"
+              name="name"
+              rules={[{ required: true, message: '请输入实例名称' }]}
+            >
+              <Input placeholder="默认取云上实例名称" />
+            </Form.Item>
+            <Form.Item label="备注" name="remark">
+              <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
+            </Form.Item>
+          </Form>
+        ) : vendor === 'TENCENT' ? (
           <Alert
             type="info"
             showIcon
-            style={{ marginBottom: 16 }}
-            message="接入地址为客户端访问入口"
-            description="接入地址会展示在 Topic 等页面供客户端配置使用。若客户端环境无法解析该地址(如 K8s 内部 
Service 域名),可自行配置 DNS 解析或在客户端 hosts 中映射。"
+            message="Tencent 版接入开发中"
+            description="腾讯云 TDMQ RocketMQ 版接入正在开发中,敬请期待。"
           />
-          <Form.Item label="备注" name="remark">
-            <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
-          </Form.Item>
-        </Form>
+        ) : (
+          <Form form={addForm} layout="vertical">
+            <Form.Item
+              label="实例名称"
+              name="name"
+              rules={[{ required: true, message: '请输入实例名称' }]}
+            >
+              <Input placeholder="例:rocketmq-production" />
+            </Form.Item>
+            <Form.Item
+              label="接入方式"
+              name="type"
+              rules={[{ required: true, message: '请选择接入方式' }]}
+            >
+              <Select
+                placeholder="选择接入方式"
+                options={[
+                  { value: 'PROXY', label: 'Proxy 模式' },
+                  { value: 'DIRECT', label: 'Direct 模式' },
+                ]}
+              />
+            </Form.Item>
+            <Form.Item
+              label="接入地址"
+              name="endpoint"
+              rules={[{ required: true, message: '请输入接入地址' }]}
+              extra={
+                addInstanceType === 'DIRECT'
+                  ? 'Direct 模式请填写 NameServer SLB 地址(K8s 场景下一般为 NameServer 
Service 地址,如 namesrv.mq.svc:9876)'
+                  : addInstanceType === 'PROXY'
+                    ? 'Proxy 模式请填写 Proxy SLB 内网地址(如 proxy.mq.svc:8080)'
+                    : '请先选择接入方式'
+              }
+            >
+              <Input
+                placeholder={
+                  addInstanceType === 'DIRECT'
+                    ? '例:namesrv.mq.svc.cluster.local:9876'
+                    : '例:proxy.mq.svc.cluster.local:8080'
+                }
+              />
+            </Form.Item>
+            <Alert
+              type="info"
+              showIcon
+              style={{ marginBottom: 16 }}
+              message="接入地址为客户端访问入口"
+              description="接入地址会展示在 Topic 等页面供客户端配置使用。若客户端环境无法解析该地址(如 K8s 内部 
Service 域名),可自行配置 DNS 解析或在客户端 hosts 中映射。"
+            />
+            <Form.Item label="备注" name="remark">
+              <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
+            </Form.Item>
+          </Form>
+        )}
       </Modal>
 
       {/* Edit Instance Modal */}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 4dbe5120..ea6d0e05 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -269,6 +269,8 @@ const TopicPage = () => {
   const { t } = useLang();
   const { selectedInstanceId, selectedInstance, selectInstance, 
instanceOptions } =
     useInstanceFilter();
+  const isCloudInstance =
+    selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor === 
'TENCENT';
 
   // ─── State ─────────────────────────────────────────────────────
   const [topics, setTopics] = useState<Topic[]>([]);
@@ -300,23 +302,32 @@ const TopicPage = () => {
   const [importErrors, setImportErrors] = useState<string[]>([]);
   const [importing, setImporting] = useState(false);
 
+  const topicRequestIdRef = useRef(0);
+
   useEffect(() => {
-    let cancelled = false;
-    void listTopics()
-      .then((nextTopics) => {
-        if (!cancelled) setTopics(nextTopics);
-      })
-      .catch(() => {
-        if (!cancelled) message.error('Topic 列表加载失败,请稍后重试');
-      })
-      .finally(() => {
-        if (!cancelled) setLoading(false);
-      });
+    if (!selectedInstanceId) {
+      return;
+    }
+    const requestId = ++topicRequestIdRef.current;
+    const timer = window.setTimeout(() => {
+      setLoading(true);
+      void listTopics({ instanceId: selectedInstanceId })
+        .then((nextTopics) => {
+          if (requestId === topicRequestIdRef.current) setTopics(nextTopics);
+        })
+        .catch(() => {
+          if (requestId === topicRequestIdRef.current)
+            message.error('Topic 列表加载失败,请稍后重试');
+        })
+        .finally(() => {
+          if (requestId === topicRequestIdRef.current) setLoading(false);
+        });
+    }, 0);
 
     return () => {
-      cancelled = true;
+      window.clearTimeout(timer);
     };
-  }, []);
+  }, [selectedInstanceId]);
 
   // ─── Filtered data ─────────────────────────────────────────────
   const filteredTopics = useMemo(
@@ -345,11 +356,11 @@ const TopicPage = () => {
     setDetailModalOpen(true);
     setDetailLoading(true);
     try {
-      const [routes, consumers] = await Promise.all([
-        getTopicRoutes(topic.name),
-        getTopicConsumers(topic.name),
-      ]);
-      setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes }));
+      const consumers = await getTopicConsumers(topic.name, selectedInstanceId 
|| undefined);
+      if (!isCloudInstance) {
+        const routes = await getTopicRoutes(topic.name, selectedInstanceId || 
undefined);
+        setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes 
}));
+      }
       setConsumersByTopic((previous) => ({ ...previous, [topic.name]: 
consumers }));
     } catch {
       message.error('Topic 详情加载失败,请稍后重试');
@@ -401,7 +412,7 @@ const TopicPage = () => {
         cancelText: '取消',
         onOk: async () => {
           try {
-            await deleteTopic(topic.name);
+            await deleteTopic(topic.name, selectedInstanceId || undefined);
             setTopics((previous) => previous.filter((item) => item.name !== 
topic.name));
             message.success(`Topic「${topic.name}」已删除`);
           } catch {
@@ -431,7 +442,7 @@ const TopicPage = () => {
       dataIndex: 'remark',
       key: 'remark',
       width: 200,
-      sorter: (a, b) => a.remark.localeCompare(b.remark),
+      sorter: (a, b) => (a.remark ?? '').localeCompare(b.remark ?? ''),
       render: (remark: string) => (
         <Text
           type="secondary"
@@ -447,7 +458,7 @@ const TopicPage = () => {
       dataIndex: 'type',
       key: 'type',
       width: 100,
-      sorter: (a, b) => a.type.localeCompare(b.type),
+      sorter: (a, b) => (a.type ?? '').localeCompare(b.type ?? ''),
       render: (type: string) => {
         const cfg = TOPIC_TYPE_MAP[type];
         return cfg ? <Tag color={cfg.color}>{t(cfg.labelKey)}</Tag> : 
<Tag>{type}</Tag>;
@@ -464,7 +475,7 @@ const TopicPage = () => {
       dataIndex: 'createdAt',
       key: 'createdAt',
       width: 170,
-      sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+      sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
       render: (d: string) => <Text type="secondary">{formatDateTime(d)}</Text>,
     },
     {
@@ -472,7 +483,7 @@ const TopicPage = () => {
       dataIndex: 'updatedAt',
       key: 'updatedAt',
       width: 170,
-      sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
+      sorter: (a, b) => (a.updatedAt ?? '').localeCompare(b.updatedAt ?? ''),
       render: (d: string) => <Text type="secondary">{formatDateTime(d)}</Text>,
     },
     {
@@ -489,14 +500,16 @@ const TopicPage = () => {
           >
             详情
           </Button>
-          <Button
-            size="small"
-            icon={<SendOutlined />}
-            style={{ borderColor: '#52c41a', color: '#52c41a' }}
-            onClick={() => handleAction('send', record)}
-          >
-            发送
-          </Button>
+          {!isCloudInstance && (
+            <Button
+              size="small"
+              icon={<SendOutlined />}
+              style={{ borderColor: '#52c41a', color: '#52c41a' }}
+              onClick={() => handleAction('send', record)}
+            >
+              发送
+            </Button>
+          )}
           <Button
             size="small"
             icon={<DeleteOutlined />}
@@ -785,6 +798,7 @@ const TopicPage = () => {
         key: values.key || undefined,
         body: values.body,
         properties: props,
+        instanceId: selectedInstanceId || undefined,
       });
       // Keep the modal open for consecutive sends
       message.success(`消息发送成功!MsgId: ${result.msgId}`);
@@ -1006,39 +1020,43 @@ const TopicPage = () => {
             </Text>
             {renderDetailTab(selectedTopic)}
 
-            <Divider style={{ margin: '20px 0 16px' }} />
+            {!isCloudInstance && (
+              <>
+                <Divider style={{ margin: '20px 0 16px' }} />
 
-            {/* Section 2: 路由信息 */}
-            <Text strong style={{ fontSize: 14, display: 'block', 
marginBottom: 12 }}>
-              路由信息
-            </Text>
-            {!detailLoading && getRoutes(selectedTopic.name).length === 0 && (
-              <Alert
-                type="warning"
-                showIcon
-                style={{ marginBottom: 12 }}
-                message="Broker 上没有该 Topic 的路由"
-                description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker 
上创建或已被删除。可按库中记录的队列数重建。"
-                action={
-                  <Button
-                    size="small"
-                    type="primary"
-                    loading={rebuilding}
-                    onClick={() => void rebuildTopic(selectedTopic)}
-                  >
-                    在 Broker 上重建
-                  </Button>
-                }
-              />
+                {/* Section 2: 路由信息 */}
+                <Text strong style={{ fontSize: 14, display: 'block', 
marginBottom: 12 }}>
+                  路由信息
+                </Text>
+                {!detailLoading && getRoutes(selectedTopic.name).length === 0 
&& (
+                  <Alert
+                    type="warning"
+                    showIcon
+                    style={{ marginBottom: 12 }}
+                    message="Broker 上没有该 Topic 的路由"
+                    description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker 
上创建或已被删除。可按库中记录的队列数重建。"
+                    action={
+                      <Button
+                        size="small"
+                        type="primary"
+                        loading={rebuilding}
+                        onClick={() => void rebuildTopic(selectedTopic)}
+                      >
+                        在 Broker 上重建
+                      </Button>
+                    }
+                  />
+                )}
+                <Table<BrokerRoute>
+                  columns={routeColumns}
+                  dataSource={getRoutes(selectedTopic.name)}
+                  rowKey="brokerName"
+                  pagination={false}
+                  size="small"
+                  loading={detailLoading}
+                />
+              </>
             )}
-            <Table<BrokerRoute>
-              columns={routeColumns}
-              dataSource={getRoutes(selectedTopic.name)}
-              rowKey="brokerName"
-              pagination={false}
-              size="small"
-              loading={detailLoading}
-            />
 
             <Divider style={{ margin: '20px 0 16px' }} />
 
@@ -1097,39 +1115,47 @@ const TopicPage = () => {
           </Form.Item>
 
           <Form.Item label="类型" name="type" rules={[{ required: true }]}>
-            <Select options={TYPE_OPTIONS.filter((o) => o.value)} />
+            <Select
+              options={TYPE_OPTIONS.filter(
+                (o) => o.value && (!isCloudInstance || o.value !== 'LITE'),
+              )}
+            />
           </Form.Item>
 
-          <Row gutter={16}>
-            <Col span={12}>
-              <Form.Item
-                label="写队列数"
-                name="writeQueues"
-                rules={[{ required: true }]}
-                extra="每个 Broker 节点 8 个队列"
-              >
-                <InputNumber min={1} max={256} style={{ width: '100%' }} />
-              </Form.Item>
-            </Col>
-            <Col span={12}>
-              <Form.Item
-                label="读队列数"
-                name="readQueues"
-                rules={[{ required: true }]}
-                extra="每个 Broker 节点 8 个队列"
-              >
-                <InputNumber min={1} max={256} style={{ width: '100%' }} />
-              </Form.Item>
-            </Col>
-          </Row>
+          {!isCloudInstance && (
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Item
+                  label="写队列数"
+                  name="writeQueues"
+                  rules={[{ required: true }]}
+                  extra="每个 Broker 节点 8 个队列"
+                >
+                  <InputNumber min={1} max={256} style={{ width: '100%' }} />
+                </Form.Item>
+              </Col>
+              <Col span={12}>
+                <Form.Item
+                  label="读队列数"
+                  name="readQueues"
+                  rules={[{ required: true }]}
+                  extra="每个 Broker 节点 8 个队列"
+                >
+                  <InputNumber min={1} max={256} style={{ width: '100%' }} />
+                </Form.Item>
+              </Col>
+            </Row>
+          )}
 
-          <Form.Item label="权限" name="perm" rules={[{ required: true }]}>
-            <Radio.Group>
-              <Radio.Button value="RW">读写</Radio.Button>
-              <Radio.Button value="RO">只读</Radio.Button>
-              <Radio.Button value="WO">只写</Radio.Button>
-            </Radio.Group>
-          </Form.Item>
+          {!isCloudInstance && (
+            <Form.Item label="权限" name="perm" rules={[{ required: true }]}>
+              <Radio.Group>
+                <Radio.Button value="RW">读写</Radio.Button>
+                <Radio.Button value="RO">只读</Radio.Button>
+                <Radio.Button value="WO">只写</Radio.Button>
+              </Radio.Group>
+            </Form.Item>
+          )}
 
           <Form.Item label="备注" name="remark">
             <Input.TextArea rows={3} placeholder="可选,描述 Topic 用途" />
diff --git a/web/src/pages/instance/vendorOptions.ts 
b/web/src/pages/instance/vendorOptions.ts
new file mode 100644
index 00000000..5da6130d
--- /dev/null
+++ b/web/src/pages/instance/vendorOptions.ts
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+import apacheFeatherLogo from '../../assets/logos/apache-feather.svg';
+import alibabaCloudLogo from '../../assets/logos/alibabacloud.svg';
+import tencentCloudLogo from '../../assets/logos/tencentcloud.svg';
+
+export type InstanceVendor = 'APACHE' | 'ALIYUN' | 'TENCENT';
+
+export interface VendorOption {
+  key: InstanceVendor;
+  label: string;
+  logo: string;
+  description: string;
+}
+
+export const VENDOR_OPTIONS: VendorOption[] = [
+  {
+    key: 'APACHE',
+    label: '开源版',
+    logo: apacheFeatherLogo,
+    description: '接入自建 Apache RocketMQ 开源集群,支持 Proxy / Direct 两种接入方式',
+  },
+  {
+    key: 'ALIYUN',
+    label: 'Aliyun 版',
+    logo: alibabaCloudLogo,
+    description: '选择已录入的云凭据与云上实例完成接入,接入点自动解析',
+  },
+  {
+    key: 'TENCENT',
+    label: 'Tencent 版',
+    logo: tencentCloudLogo,
+    description: '接入腾讯云 TDMQ RocketMQ 版实例,接入地址填写实例的接入点',
+  },
+];
+
+export const DEFAULT_VENDOR: InstanceVendor = 'APACHE';
diff --git a/web/src/services/consumerService.ts 
b/web/src/services/consumerService.ts
index 256c1b0c..c46d5362 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -51,11 +51,14 @@ export async function listConsumerGroups(params?: 
ConsumerGroupQuery): Promise<C
   return metadataApi.listConsumerGroups(params);
 }
 
-export async function getConsumerProgress(name: string): 
Promise<QueueProgress[]> {
+export async function getConsumerProgress(
+  name: string,
+  instanceId?: string,
+): Promise<QueueProgress[]> {
   if (isMockMode()) {
     return ((mockQueueProgress[name] as unknown as QueueProgress[]) ?? 
[]).map(copyQueueProgress);
   }
-  return metadataApi.getConsumerProgress(name);
+  return metadataApi.getConsumerProgress(name, instanceId);
 }
 
 export async function getConsumerGroup(name: string): 
Promise<ConsumerGroupDetail> {
@@ -67,13 +70,16 @@ export async function getConsumerGroup(name: string): 
Promise<ConsumerGroupDetai
   return metadataApi.getConsumerGroup(name);
 }
 
-export async function getConsumerSubscriptions(name: string): 
Promise<SubscriptionEntry[]> {
+export async function getConsumerSubscriptions(
+  name: string,
+  instanceId?: string,
+): Promise<SubscriptionEntry[]> {
   if (isMockMode()) {
     return ((mockSubscriptions[name] as unknown as SubscriptionEntry[]) ?? 
[]).map(
       copySubscription,
     );
   }
-  return metadataApi.getConsumerSubscriptions(name);
+  return metadataApi.getConsumerSubscriptions(name, instanceId);
 }
 
 export async function createConsumerGroup(data: Partial<ConsumerGroup>): 
Promise<ConsumerGroup> {
@@ -101,13 +107,13 @@ export async function createConsumerGroup(data: 
Partial<ConsumerGroup>): Promise
   return metadataApi.createConsumerGroup(data);
 }
 
-export async function deleteConsumerGroup(name: string): Promise<void> {
+export async function deleteConsumerGroup(name: string, instanceId?: string): 
Promise<void> {
   if (isMockMode()) {
     const idx = consumerGroupsState.findIndex((group) => group.name === name);
     if (idx >= 0) consumerGroupsState.splice(idx, 1);
     return;
   }
-  return metadataApi.deleteConsumerGroup(name);
+  return metadataApi.deleteConsumerGroup(name, instanceId);
 }
 
 export async function resetConsumerOffset(data: ResetConsumerOffsetRequest): 
Promise<void> {
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 54121765..1f7687f6 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -36,6 +36,10 @@ export async function createInstance(data: 
CreateInstanceRequest): Promise<Insta
     const instance: Instance = {
       id: String(Date.now()),
       ...data,
+      name: data.name || '',
+      type: data.type || 'PROXY',
+      endpoint: data.endpoint || '',
+      vendor: data.vendor || 'APACHE',
       remark: data.remark || '',
       topicCount: 0,
       consumerGroupCount: 0,
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 90617928..86074bf4 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -60,13 +60,13 @@ export async function updateTopic(data: Partial<Topic>): 
Promise<Topic> {
   return metadataApi.updateTopic(data);
 }
 
-export async function deleteTopic(name: string): Promise<void> {
+export async function deleteTopic(name: string, instanceId?: string): 
Promise<void> {
   if (isMockMode()) {
     const idx = mockTopics.findIndex((t) => t.name === name);
     if (idx >= 0) mockTopics.splice(idx, 1);
     return;
   }
-  return metadataApi.deleteTopic(name);
+  return metadataApi.deleteTopic(name, instanceId);
 }
 
 export interface BatchDeleteTopicsResult {
@@ -88,15 +88,18 @@ export async function batchDeleteTopics(names: string[]): 
Promise<BatchDeleteTop
   return result;
 }
 
-export async function getTopicRoutes(name: string): Promise<BrokerRoute[]> {
+export async function getTopicRoutes(name: string, instanceId?: string): 
Promise<BrokerRoute[]> {
   if (isMockMode()) return cloneRoutes((topicRoutes[name] as unknown as 
BrokerRoute[]) ?? []);
-  return metadataApi.getTopicRoutes(name);
+  return metadataApi.getTopicRoutes(name, instanceId);
 }
 
-export async function getTopicConsumers(name: string): 
Promise<ConsumerGroupInfo[]> {
+export async function getTopicConsumers(
+  name: string,
+  instanceId?: string,
+): Promise<ConsumerGroupInfo[]> {
   if (isMockMode())
     return cloneConsumers((topicConsumers[name] as unknown as 
ConsumerGroupInfo[]) ?? []);
-  return metadataApi.getTopicConsumers(name);
+  return metadataApi.getTopicConsumers(name, instanceId);
 }
 
 export async function sendTopicMessage(
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 4df61936..b9a150e4 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -20,7 +20,8 @@ const pad = (n: number, width = 2): string => 
String(n).padStart(width, '0');
 /**
  * Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
  */
-export function formatDateTime(date: string | Date): string {
+export function formatDateTime(date: string | Date | null | undefined): string 
{
+  if (date === null || date === undefined) return '-';
   const d = typeof date === 'string' ? new Date(date) : date;
   if (isNaN(d.getTime())) return String(date);
   return (
@@ -32,7 +33,8 @@ export function formatDateTime(date: string | Date): string {
 /**
  * Format a date string or Date object to 'YYYY-MM-DD'.
  */
-export function formatDate(date: string | Date): string {
+export function formatDate(date: string | Date | null | undefined): string {
+  if (date === null || date === undefined) return '-';
   const d = typeof date === 'string' ? new Date(date) : date;
   if (isNaN(d.getTime())) return String(date);
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;

Reply via email to