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 50ba961ec feat(topic): support topic bulk import and export (#2640)
50ba961ec is described below

commit 50ba961ecd453cd371595ea64be225da81e49a26
Author: coder999o <[email protected]>
AuthorDate: Mon Aug 31 21:08:46 2026 +0800

    feat(topic): support topic bulk import and export (#2640)
---
 .../studio/instance/topic/ImportTopicsDTO.java     | 37 +++++++++
 .../instance/topic/ImportTopicsResultVO.java       | 52 ++++++++++++
 .../studio/instance/topic/MetadataService.java     | 78 ++++++++++++++++++
 .../studio/instance/topic/TopicController.java     | 28 +++++++
 .../studio/instance/topic/MetadataServiceTest.java | 92 +++++++++++++++++++++
 .../studio/instance/topic/TopicControllerTest.java | 54 ++++++++++++
 web/src/api/metadata.test.ts                       | 63 ++++++++++++++
 web/src/api/metadata.ts                            | 34 ++++++++
 .../pages/instance/__tests__/TopicPage.test.tsx    | 96 +++++++++++++---------
 web/src/pages/instance/topic.tsx                   | 69 +++++++---------
 web/src/services/topicService.ts                   | 58 +++++++++++++
 11 files changed, 585 insertions(+), 76 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsDTO.java
new file mode 100644
index 000000000..0f2083f80
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsDTO.java
@@ -0,0 +1,37 @@
+/*
+ * 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.topic;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class ImportTopicsDTO {
+
+    @NotBlank(message = "instanceId is required")
+    private String instanceId;
+
+    @Valid
+    @NotEmpty(message = "topics is required")
+    @Size(max = 100, message = "At most 100 topics are allowed per import")
+    private List<CreateTopicDTO> topics;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsResultVO.java
new file mode 100644
index 000000000..34994d9b3
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/ImportTopicsResultVO.java
@@ -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.
+ */
+package org.apache.rocketmq.studio.instance.topic;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ImportTopicsResultVO {
+
+    private int imported;
+
+    private int failed;
+
+    private List<TopicVO> topics;
+
+    private List<Failure> failures;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class Failure {
+
+        private int index;
+
+        private String name;
+
+        private String message;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index cd9a47b7b..f482401a4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -27,6 +27,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.CsvUtil;
+import org.apache.rocketmq.studio.common.util.SystemTopicFilter;
 import org.apache.rocketmq.studio.instance.group.CreateConsumerGroupDTO;
 import org.apache.rocketmq.studio.instance.group.ImportConsumerGroupsResultVO;
 import org.springframework.util.StringUtils;
@@ -112,6 +113,9 @@ public class MetadataService {
 
     public TopicVO createTopic(TopicVO topic) {
         requireTopic(topic);
+        if (SystemTopicFilter.isSystem(topic.getName())) {
+            throw new BusinessException(400, "System topics cannot be created: 
" + topic.getName());
+        }
         String instanceId = topic.getInstanceId();
         InstanceProvider provider = resolve(instanceId);
         return executeWithAudit(provider, Operation.CREATE_TOPIC, 
ResourceType.TOPIC, topic.getName(),
@@ -457,6 +461,80 @@ public class MetadataService {
         return value.toString();
     }
 
+    private static final int MAX_IMPORT_TOPICS = 100;
+
+    public ImportTopicsResultVO importTopics(String instanceId, 
List<CreateTopicDTO> topics) {
+        if (!StringUtils.hasText(instanceId)) {
+            throw new BusinessException(400, "instanceId is required");
+        }
+        if (topics == null || topics.isEmpty()) {
+            throw new BusinessException(400, "topics is required");
+        }
+        if (topics.size() > MAX_IMPORT_TOPICS) {
+            throw new BusinessException(400, "At most 100 topics are allowed 
per import");
+        }
+
+        String normalizedInstanceId = normalizeInstanceId(instanceId);
+        List<TopicVO> imported = new ArrayList<>();
+        List<ImportTopicsResultVO.Failure> failures = new ArrayList<>();
+        for (int index = 0; index < topics.size(); index++) {
+            CreateTopicDTO request = topics.get(index);
+            String name = request == null ? null : request.getName();
+            try {
+                if (request == null) {
+                    throw new BusinessException(400, "topic request is 
required");
+                }
+                TopicVO topic = request.toTopicVO();
+                topic.setInstanceId(normalizedInstanceId);
+                imported.add(createTopic(topic));
+            } catch (Exception exception) {
+                failures.add(ImportTopicsResultVO.Failure.builder()
+                        .index(index)
+                        .name(name)
+                        .message(StringUtils.hasText(exception.getMessage())
+                                ? exception.getMessage() : "Failed to create 
topic")
+                        .build());
+            }
+        }
+
+        return ImportTopicsResultVO.builder()
+                .imported(imported.size())
+                .failed(failures.size())
+                .topics(imported)
+                .failures(failures)
+                .build();
+    }
+
+    public String exportTopics(String instanceId, String type, String search, 
List<String> names) {
+        instanceId = normalizeInstanceId(instanceId);
+        List<TopicVO> topics = new ArrayList<>(listTopics(instanceId, null, 
type, search));
+        Set<String> selectedNames = new HashSet<>(names == null ? List.of() : 
names);
+        if (!selectedNames.isEmpty()) {
+            topics.removeIf(topic -> !selectedNames.contains(topic.getName()));
+        }
+        topics.sort((left, right) -> compareNames(left.getName(), 
right.getName()));
+        return buildTopicCsv(topics);
+    }
+
+    private int compareNames(String left, String right) {
+        String leftName = left == null ? "" : left;
+        String rightName = right == null ? "" : right;
+        return leftName.compareTo(rightName);
+    }
+
+    private String buildTopicCsv(List<TopicVO> topics) {
+        StringBuilder csv = new StringBuilder();
+        CsvUtil.appendRow(csv, "Name", "Namespace", "Type", "Cluster ID", 
"Write Queues", "Read Queues",
+                "Permission", "Message Count", "TPS", "Consumer Groups", 
"Remark", "Created At", "Updated At");
+        for (TopicVO topic : topics) {
+            CsvUtil.appendRow(csv, topic.getName(), topic.getNamespace(), 
toText(topic.getType()), topic.getClusterId(),
+                    topic.getWriteQueues(), topic.getReadQueues(), 
toText(topic.getPerm()), topic.getMessageCount(),
+                    topic.getTps(), topic.getConsumerGroupCount(), 
topic.getRemark(),
+                    topic.getGmtCreate(), topic.getGmtModified());
+        }
+        return csv.toString();
+    }
+
     private String requireName(String value, String fieldName) {
         if (!StringUtils.hasText(value)) {
             throw new BusinessException(400, fieldName + " is required");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicController.java
index eab11d963..cb8f88291 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicController.java
@@ -29,6 +29,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 @RestController
@@ -59,6 +61,21 @@ public class TopicController {
         return Result.ok(metadataService.listTopicsPage(instanceId, clusterId, 
type, search, page, pageSize));
     }
 
+    @GetMapping("/export")
+    public Result<String> exportTopics(
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(required = false) String type,
+            @RequestParam(required = false) String search,
+            @RequestParam(required = false) String names) {
+        return Result.ok(metadataService.exportTopics(instanceId, type, 
search, parseNames(names)));
+    }
+
+    @PostMapping("/import")
+    public Result<ImportTopicsResultVO> importTopics(@Valid @RequestBody 
ImportTopicsDTO request) {
+        String instanceId = 
instanceService.normalizeIdentifier(request.getInstanceId());
+        return Result.ok(metadataService.importTopics(instanceId, 
request.getTopics()));
+    }
+
     @PostMapping("/create")
     public Result<TopicVO> createTopic(@Valid @RequestBody(required = false) 
CreateTopicDTO topic) {
         requireCreateTopicRequest(topic);
@@ -134,4 +151,15 @@ public class TopicController {
             throw new BusinessException(400, "Topic send message request is 
required");
         }
     }
+
+    private List<String> parseNames(String names) {
+        if (names == null || names.isBlank()) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(names.split(","))
+                .map(String::trim)
+                .filter(name -> !name.isEmpty())
+                .distinct()
+                .toList();
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 1c6250963..82207638a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -22,6 +22,8 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
 import org.apache.rocketmq.studio.instance.group.CreateConsumerGroupDTO;
@@ -176,6 +178,20 @@ class MetadataServiceTest {
         verifyNoInteractions(operationAuditService);
     }
 
+    @Test
+    void createTopicShouldRejectSystemTopicNamesTest() {
+        TopicVO systemTopic = new TopicVO();
+        systemTopic.setName("TBW102");
+        systemTopic.setWriteQueues(8);
+        systemTopic.setReadQueues(8);
+
+        assertThatThrownBy(() -> metadataService.createTopic(systemTopic))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("System topics cannot be created");
+
+        verify(apacheProvider, org.mockito.Mockito.never()).createTopic(any(), 
any(TopicVO.class));
+    }
+
     @Test
     void apacheTopicWriteOperationsShouldNotDuplicateProviderAudit() {
         TopicVO topic = new TopicVO();
@@ -249,6 +265,52 @@ class MetadataServiceTest {
                 "type=-, writeQueues=4, readQueues=4, perm=-", "FAILED", "open 
api unavailable");
     }
 
+    @Test
+    void exportTopicsShouldApplyFiltersSelectedNamesSortingAndCsvEscaping() {
+        TopicVO low = topic("orders-low", "\t=orders", TopicType.NORMAL);
+        TopicVO high = topic("orders-high", "critical", TopicType.FIFO);
+        TopicVO hidden = topic("users-topic", "=formula", TopicType.NORMAL);
+        when(apacheProvider.listTopics("instance-a", "NORMAL", 
"orders")).thenReturn(List.of(low, hidden, high));
+
+        String csv = metadataService.exportTopics("instance-a", " NORMAL ", " 
orders ",
+                List.of("orders-high", "orders-low"));
+
+        assertThat(csv).contains("\"Name\",\"Namespace\",\"Type\"");
+        assertThat(csv).contains("\"orders-high\",\"critical\",\"FIFO\"");
+        assertThat(csv).contains("\"orders-low\",\"'\t=orders\",\"NORMAL\"");
+        assertThat(csv).doesNotContain("users-topic");
+        
assertThat(csv.indexOf("\"orders-high\"")).isLessThan(csv.indexOf("\"orders-low\""));
+        verify(apacheProvider).listTopics("instance-a", "NORMAL", "orders");
+    }
+
+    @Test
+    void importTopicsShouldContinueAfterRowFailure() {
+        when(apacheProvider.createTopic(eq("instance-a"), 
any(TopicVO.class))).thenAnswer(invocation -> {
+            TopicVO topic = invocation.getArgument(1);
+            if ("topic-fail".equals(topic.getName())) {
+                throw new BusinessException(500, "broker rejected topic");
+            }
+            topic.setClusterId("cluster-a");
+            return topic;
+        });
+
+        ImportTopicsResultVO result = 
metadataService.importTopics("instance-a",
+                List.of(topicImportRequest("topic-ok", "other-instance"), 
topicImportRequest("topic-fail", "other-instance")));
+
+        assertThat(result.getImported()).isEqualTo(1);
+        assertThat(result.getFailed()).isEqualTo(1);
+        
assertThat(result.getTopics()).extracting(TopicVO::getName).containsExactly("topic-ok");
+        assertThat(result.getFailures()).hasSize(1);
+        assertThat(result.getFailures().get(0).getIndex()).isEqualTo(1);
+        
assertThat(result.getFailures().get(0).getName()).isEqualTo("topic-fail");
+        assertThat(result.getFailures().get(0).getMessage()).isEqualTo("broker 
rejected topic");
+
+        ArgumentCaptor<TopicVO> captor = 
ArgumentCaptor.forClass(TopicVO.class);
+        verify(apacheProvider, 
org.mockito.Mockito.times(2)).createTopic(eq("instance-a"), captor.capture());
+        assertThat(captor.getAllValues()).extracting(TopicVO::getInstanceId)
+                .containsExactly("instance-a", "instance-a");
+    }
+
     @Test
     void deleteTopicShouldDelegateToApacheProvider() {
         metadataService.deleteTopic("topic-to-delete");
@@ -577,4 +639,34 @@ class MetadataServiceTest {
 
     }
 
+    private TopicVO topic(String name, String namespace, TopicType type) {
+        TopicVO topic = new TopicVO();
+        topic.setName(name);
+        topic.setNamespace(namespace);
+        topic.setType(type);
+        topic.setClusterId("cluster-a");
+        topic.setWriteQueues(8);
+        topic.setReadQueues(8);
+        topic.setPerm(TopicPerm.RW);
+        topic.setMessageCount(100);
+        topic.setTps(2.5);
+        topic.setConsumerGroupCount(3);
+        topic.setRemark("remark");
+        topic.setGmtCreate(LocalDateTime.of(2026, 8, 27, 10, 0));
+        topic.setGmtModified(LocalDateTime.of(2026, 8, 27, 11, 0));
+        return topic;
+    }
+
+    private CreateTopicDTO topicImportRequest(String name, String instanceId) {
+        CreateTopicDTO request = new CreateTopicDTO();
+        request.setName(name);
+        request.setInstanceId(instanceId);
+        request.setType(TopicType.NORMAL);
+        request.setWriteQueues(8);
+        request.setReadQueues(8);
+        request.setPerm(TopicPerm.RW);
+        return request;
+
+    }
+
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
index 31792bef2..b3c2c5dc5 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
@@ -144,6 +144,22 @@ class TopicControllerTest {
         verify(metadataService).getTopicConsumersPage("instance-a", "orders", 
2, 20);
     }
 
+    @Test
+    void exportTopicsShouldPassFiltersAndSelectedNames() throws Exception {
+        when(metadataService.exportTopics("instance-a", "FIFO", "orders", 
List.of("topic-a", "topic-b")))
+                .thenReturn("\"Name\"\n\"topic-a\"");
+
+        mockMvc.perform(get("/api/topics/export")
+                        .param("instanceId", "instance-a")
+                        .param("type", "FIFO")
+                        .param("search", "orders")
+                        .param("names", "topic-a, topic-b,topic-a"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data").value("\"Name\"\n\"topic-a\""));
+
+        verify(metadataService).exportTopics("instance-a", "FIFO", "orders", 
List.of("topic-a", "topic-b"));
+    }
+
     @Test
     void createTopicShouldReturnCreatedTopic() throws Exception {
         TopicVO input = new TopicVO();
@@ -176,6 +192,44 @@ class TopicControllerTest {
         assertThat(captor.getValue().getReadQueues()).isEqualTo(16);
     }
 
+    @Test
+    void importTopicsShouldNormalizeInstanceAndDelegateBatch() throws 
Exception {
+        Map<String, Object> body = Map.of(
+                "instanceId", "1",
+                "topics", List.of(Map.of(
+                        "name", "imported-topic",
+                        "type", "NORMAL",
+                        "writeQueues", 8,
+                        "readQueues", 8,
+                        "perm", "RW"
+                ))
+        );
+        TopicVO imported = new TopicVO();
+        imported.setName("imported-topic");
+        
when(instanceService.normalizeIdentifier("1")).thenReturn("open-source-local");
+        when(metadataService.importTopics(eq("open-source-local"), any()))
+                .thenReturn(ImportTopicsResultVO.builder()
+                        .imported(1)
+                        .failed(0)
+                        .topics(List.of(imported))
+                        .failures(List.of())
+                        .build());
+
+        mockMvc.perform(post("/api/topics/import")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.imported").value(1))
+                
.andExpect(jsonPath("$.data.topics[0].name").value("imported-topic"));
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<CreateTopicDTO>> captor = 
ArgumentCaptor.forClass(List.class);
+        verify(metadataService).importTopics(eq("open-source-local"), 
captor.capture());
+        assertThat(captor.getValue()).hasSize(1);
+        
assertThat(captor.getValue().get(0).getName()).isEqualTo("imported-topic");
+        assertThat(captor.getValue().get(0).getWriteQueues()).isEqualTo(8);
+    }
+
     @Test
     void createTopicShouldRejectMissingName() throws Exception {
         mockMvc.perform(post("/api/topics/create")
diff --git a/web/src/api/metadata.test.ts b/web/src/api/metadata.test.ts
index 867995474..e83e19322 100644
--- a/web/src/api/metadata.test.ts
+++ b/web/src/api/metadata.test.ts
@@ -29,6 +29,8 @@ import {
   importConsumerGroups,
   listTopics,
   sendTopicMessage,
+  exportTopics,
+  importTopics,
 } from './metadata';
 
 const mock = new MockAdapter(client);
@@ -202,4 +204,65 @@ describe('topic metadata API', () => {
       }),
     ).resolves.toMatchObject({ imported: 1, failed: 0 });
   });
+
+  it('passes topic import and export contracts through API endpoints', async 
() => {
+    mock.onGet('/topics/export').reply((config) => {
+      expect(config.params).toEqual({
+        instanceId: 'instance-1',
+        type: 'FIFO',
+        search: 'orders',
+        names: 'topic-a,topic-b',
+      });
+      return [200, { code: 200, data: '"Name"\n"topic-a"' }];
+    });
+    mock.onPost('/topics/import').reply((config) => {
+      expect(JSON.parse(config.data)).toEqual({
+        instanceId: 'instance-1',
+        topics: [
+          {
+            name: 'topic-a',
+            type: 'NORMAL',
+            writeQueues: 8,
+            readQueues: 8,
+            perm: 'RW',
+          },
+        ],
+      });
+      return [
+        200,
+        {
+          code: 200,
+          data: {
+            imported: 1,
+            failed: 0,
+            topics: [],
+            failures: [],
+          },
+        },
+      ];
+    });
+
+    await expect(
+      exportTopics({
+        instanceId: 'instance-1',
+        type: 'FIFO',
+        search: 'orders',
+        names: ['topic-a', 'topic-b'],
+      }),
+    ).resolves.toBe('"Name"\n"topic-a"');
+    await expect(
+      importTopics({
+        instanceId: 'instance-1',
+        topics: [
+          {
+            name: 'topic-a',
+            type: 'NORMAL',
+            writeQueues: 8,
+            readQueues: 8,
+            perm: 'RW',
+          },
+        ],
+      }),
+    ).resolves.toMatchObject({ imported: 1, failed: 0 });
+  });
 });
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index b919f32b3..148a8c842 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -30,6 +30,10 @@ export interface TopicQuery {
   search?: string;
 }
 
+export interface TopicExportQuery extends TopicQuery {
+  names?: string[];
+}
+
 export interface BrokerRoute {
   brokerName: string;
   brokerAddr: string;
@@ -69,6 +73,24 @@ export interface PageResult<T> {
   size: number;
 }
 
+export interface ImportTopicsRequest {
+  instanceId: string;
+  topics: Partial<Topic>[];
+}
+
+export interface ImportTopicsFailure {
+  index: number;
+  name?: string;
+  message: string;
+}
+
+export interface ImportTopicsResult {
+  imported: number;
+  failed: number;
+  topics: Topic[];
+  failures: ImportTopicsFailure[];
+}
+
 // ─── Consumer Group (matches mock/consumers.ts) ─────────────────
 export interface ConsumerGroup {
   name: string;
@@ -194,6 +216,18 @@ export async function listTopicsPage(params?: TopicQuery & 
{ page?: number; page
   return res.data.data;
 }
 
+export async function exportTopics(params?: TopicExportQuery) {
+  const res = await client.get<{ data: string }>('/topics/export', {
+    params: { ...params, names: params?.names?.join(',') },
+  });
+  return res.data.data;
+}
+
+export async function importTopics(data: ImportTopicsRequest) {
+  const res = await client.post<{ data: ImportTopicsResult 
}>('/topics/import', data);
+  return res.data.data;
+}
+
 export async function createTopic(data: Partial<Topic>) {
   const res = await client.post<{ data: Topic }>('/topics/create', data);
   return res.data.data;
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index df2e54cd0..d2003577e 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -29,9 +29,11 @@ const topicServiceMocks = vi.hoisted(() => ({
   batchDeleteTopics: vi.fn(),
   createTopic: vi.fn(),
   deleteTopic: vi.fn(),
+  exportTopics: vi.fn(),
   getTopicConsumers: vi.fn(),
   getTopicConsumerPage: vi.fn(),
   getTopicRoutes: vi.fn(),
+  importTopics: vi.fn(),
   listAllTopics: vi.fn(),
   listTopics: vi.fn(),
   listTopicsPage: vi.fn(),
@@ -134,6 +136,7 @@ describe('TopicPage', () => {
   beforeEach(() => {
     mockTopicsList(buildTopics(25));
     topicServiceMocks.listAllTopics.mockResolvedValue(buildTopics(25));
+    topicServiceMocks.exportTopics.mockResolvedValue('"Name"\n"topic-01"');
     topicServiceMocks.batchDeleteTopics.mockResolvedValue({ deleted: [], 
failed: [] });
     topicServiceMocks.createTopic.mockImplementation(async (data: 
Partial<Topic>) => ({
       ...buildTopics(1)[0],
@@ -146,6 +149,24 @@ describe('TopicPage', () => {
       gmtCreate: '2026-01-02T00:00:00Z',
       gmtModified: '2026-01-02T00:00:00Z',
     }));
+    topicServiceMocks.importTopics.mockResolvedValue({
+      imported: 1,
+      failed: 0,
+      topics: [
+        {
+          ...buildTopics(1)[0],
+          name: 'imported-topic',
+          namespace: 'default',
+          clusterId: 'server-cluster',
+          messageCount: 0,
+          tps: 0,
+          consumerGroupCount: 0,
+          gmtCreate: '2026-01-02T00:00:00Z',
+          gmtModified: '2026-01-02T00:00:00Z',
+        },
+      ],
+      failures: [],
+    });
     topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
     topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
     topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
@@ -218,24 +239,14 @@ describe('TopicPage', () => {
         remark: '\t=orders, "critical"',
       },
     ];
-    const archivedTopic = {
-      ...buildTopics(1)[0],
-      name: 'orders-topic-archive',
-      namespace: 'trade',
-      remark: '=archive',
-    };
-    const allMatchingTopics = [
-      ...currentPageTopics,
-      archivedTopic,
-      {
-        ...buildTopics(1)[0],
-        name: 'users-topic',
-        namespace: 'user',
-        remark: '=formula-risk',
-      },
-    ];
     mockTopicsList(currentPageTopics);
-    topicServiceMocks.listAllTopics.mockResolvedValue(allMatchingTopics);
+    topicServiceMocks.exportTopics.mockResolvedValue(
+      [
+        '"Name","Namespace","Remark"',
+        '"orders-topic","trade","\'\t=orders, ""critical"""',
+        '"orders-topic-archive","trade","\'=archive"',
+      ].join('\n'),
+    );
     renderWithProviders();
 
     expect(await screen.findByText('orders-topic')).toBeInTheDocument();
@@ -245,12 +256,13 @@ describe('TopicPage', () => {
     await user.click(screen.getByRole('button', { name: /导出/ }));
 
     await waitFor(() =>
-      expect(topicServiceMocks.listAllTopics).toHaveBeenCalledWith({
+      expect(topicServiceMocks.exportTopics).toHaveBeenCalledWith({
         instanceId: 'instance-proxy-1',
         type: undefined,
         search: 'orders',
       }),
     );
+    expect(topicServiceMocks.listAllTopics).not.toHaveBeenCalled();
     expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
     expect(clickSpy).toHaveBeenCalledTimes(1);
     expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:topic-export');
@@ -458,7 +470,7 @@ describe('TopicPage', () => {
     expect(screen.getByText('10.0.2.21:8080')).toBeInTheDocument();
   });
 
-  it('imports valid topic CSV rows through the create service with the 
selected instance', async () => {
+  it('imports valid topic CSV rows through the backend batch service with the 
selected instance', async () => {
     const user = userEvent.setup();
     mockTopicsList([]);
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
@@ -470,25 +482,28 @@ describe('TopicPage', () => {
       
'"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
     ].join('\n');
     await user.upload(screen.getByTestId('topic-import-file'), new File([csv], 
'topics.csv'));
-    expect(await screen.findByText('检测到 1 个 
Topic,将按顺序调用创建接口')).toBeInTheDocument();
+    expect(await screen.findByText('检测到 1 个 
Topic,将通过后端批量导入')).toBeInTheDocument();
     await user.click(screen.getByRole('button', { name: '开始导入' }));
 
     await waitFor(() =>
-      expect(topicServiceMocks.createTopic).toHaveBeenCalledWith({
-        name: 'imported-topic',
-        type: 'NORMAL',
-        writeQueues: 4,
-        readQueues: 6,
-        perm: 'RW',
-        remark: 'orders',
-        instanceId: 'instance-proxy-1',
-      }),
+      
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1', 
[
+        {
+          name: 'imported-topic',
+          type: 'NORMAL',
+          writeQueues: 4,
+          readQueues: 6,
+          perm: 'RW',
+          remark: 'orders',
+          instanceId: 'instance-proxy-1',
+        },
+      ]),
     );
+    expect(topicServiceMocks.createTopic).not.toHaveBeenCalled();
     expect(await screen.findByText('已导入 1 个 Topic')).toBeInTheDocument();
     expect(screen.getAllByText('imported-topic').length).toBeGreaterThan(0);
   });
 
-  it('does not call createTopic when imported topic CSV is invalid or 
duplicated', async () => {
+  it('does not call importTopics when imported topic CSV is invalid or 
duplicated', async () => {
     const user = userEvent.setup();
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
     mockTopicsList([{ ...buildTopics(1)[0], instanceId: 'instance-proxy-1' }]);
@@ -507,13 +522,22 @@ describe('TopicPage', () => {
     expect(screen.getAllByText(/Name 仅支持/).length).toBeGreaterThan(0);
     expect(screen.getByText(/重复/)).toBeInTheDocument();
     expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
-    expect(topicServiceMocks.createTopic).not.toHaveBeenCalled();
+    expect(topicServiceMocks.importTopics).not.toHaveBeenCalled();
   });
 
   it('imports valid topic rows while skipping duplicate rows', async () => {
     const user = userEvent.setup();
     mockTopicsList([]);
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+    topicServiceMocks.importTopics.mockResolvedValue({
+      imported: 2,
+      failed: 0,
+      topics: [
+        { ...buildTopics(1)[0], name: 'topic-a', instanceId: 
'instance-proxy-1' },
+        { ...buildTopics(1)[0], name: 'topic-b', instanceId: 
'instance-proxy-1', type: 'FIFO' },
+      ],
+      failures: [],
+    });
     renderWithProviders('/instance/instance-proxy-1/topic');
 
     await screen.findByText(/共 0 个 Topic/);
@@ -530,15 +554,11 @@ describe('TopicPage', () => {
     expect(screen.getByText(/Name 与第 2 行重复/)).toBeInTheDocument();
     await user.click(screen.getByRole('button', { name: '开始导入' }));
 
-    await waitFor(() => 
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(2));
-    expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
-      1,
+    await waitFor(() => 
expect(topicServiceMocks.importTopics).toHaveBeenCalledTimes(1));
+    
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1', 
[
       expect.objectContaining({ name: 'topic-a', instanceId: 
'instance-proxy-1' }),
-    );
-    expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
-      2,
       expect.objectContaining({ name: 'topic-b', instanceId: 
'instance-proxy-1' }),
-    );
+    ]);
     expect(await screen.findByText('已导入 2 个 Topic,1 
行无效已跳过')).toBeInTheDocument();
   });
 
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index a93506ae1..437319059 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -67,9 +67,10 @@ import {
   batchDeleteTopics,
   createTopic,
   deleteTopic,
+  exportTopics,
   getTopicConsumerPage,
   getTopicRoutes,
-  listAllTopics,
+  importTopics,
   listTopicsPage,
   sendTopicMessage,
 } from '../../services/topicService';
@@ -82,7 +83,7 @@ import {
   validateTopicCsvImport,
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
-import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { downloadCsv } from '../../utils/download';
 import { parseMessageProperties } from '../../utils/messageProperties';
 import { tableScrollX } from '../../utils/table';
 import {
@@ -147,24 +148,6 @@ const TOPIC_TYPE_CARDS = [
 // ─── Perm label ───────────────────────────────────────────────────
 const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
 
-const TOPIC_EXPORT_COLUMNS: CsvColumn<Topic>[] = [
-  { header: 'Name', value: (topic) => topic.name },
-  { header: 'Namespace', value: (topic) => topic.namespace },
-  { header: 'Type', value: (topic) => topic.type },
-  { header: 'Cluster ID', value: (topic) => topic.clusterId },
-  { header: 'Write Queues', value: (topic) => topic.writeQueues },
-  { header: 'Read Queues', value: (topic) => topic.readQueues },
-  { header: 'Permission', value: (topic) => topic.perm },
-  { header: 'Message Count', value: (topic) => topic.messageCount },
-  { header: 'TPS', value: (topic) => topic.tps },
-  { header: 'Consumer Groups', value: (topic) => topic.consumerGroupCount },
-  { header: 'Remark', value: (topic) => topic.remark },
-  { header: 'Created At', value: (topic) => topic.gmtCreate },
-  { header: 'Updated At', value: (topic) => topic.gmtModified },
-];
-
-const buildTopicCsv = (topics: Topic[]) => buildCsv(TOPIC_EXPORT_COLUMNS, 
topics);
-
 const visibleTopics = (
   topics: Topic[],
   selectedInstanceId: string | undefined,
@@ -593,18 +576,14 @@ const TopicPage = () => {
   const handleExport = () => {
     setExporting(true);
 
-    void listAllTopics({
+    void exportTopics({
       instanceId: selectedInstanceId || undefined,
       type: typeFilter || undefined,
       search: searchText.trim() || undefined,
     })
-      .then((allTopics) => {
-        const exportTopics = visibleTopics(allTopics, selectedInstanceId, 
searchText, typeFilter);
-        downloadCsv(
-          `rocketmq-topics-${new Date().toISOString().slice(0, 10)}.csv`,
-          buildTopicCsv(exportTopics),
-        );
-        message.success(`已导出 ${exportTopics.length} 个 Topic`);
+      .then((csv) => {
+        downloadCsv(`rocketmq-topics-${new Date().toISOString().slice(0, 
10)}.csv`, csv);
+        message.success('Topic 导出完成');
       })
       .catch(() => {
         message.error('导出 Topic 失败,请稍后重试');
@@ -1121,22 +1100,37 @@ const TopicPage = () => {
 
     setImporting(true);
     const nextRows = importRows.map((row) => ({ ...row }));
-    const createdTopics: Topic[] = [];
+    let createdTopics: Topic[] = [];
 
-    for (const { row, index } of targetIndexes) {
-      try {
-        const created = await createTopic(row.payload);
-        createdTopics.push(created);
-        nextRows[index] = { ...nextRows[index], status: 'success', message: 
'已创建' };
-      } catch (error) {
+    try {
+      const result = await importTopics(
+        selectedInstanceId,
+        targetIndexes.map(({ row }) => row.payload),
+      );
+      createdTopics = result.topics;
+      const failureByIndex = new Map(result.failures.map((failure) => 
[failure.index, failure]));
+      targetIndexes.forEach(({ index }, requestIndex) => {
+        const failure = failureByIndex.get(requestIndex);
+        nextRows[index] = failure
+          ? {
+              ...nextRows[index],
+              status: 'failed',
+              message: failure.message || '创建失败',
+            }
+          : { ...nextRows[index], status: 'success', message: '已创建' };
+      });
+    } catch (error) {
+      for (const { index } of targetIndexes) {
         nextRows[index] = {
           ...nextRows[index],
           status: 'failed',
           message: error instanceof Error ? error.message : '创建失败',
         };
       }
-      setImportRows([...nextRows]);
+    } finally {
+      setImporting(false);
     }
+    setImportRows([...nextRows]);
 
     if (createdTopics.length > 0) {
       setTopics((previous) => {
@@ -1158,7 +1152,6 @@ const TopicPage = () => {
     } else {
       message.error(`${failedCount} 个 Topic 导入失败`);
     }
-    setImporting(false);
   };
 
   const topicImportColumns: 
TableColumnsType<ResourceImportRow<Partial<Topic>>> = [
@@ -1624,7 +1617,7 @@ const TopicPage = () => {
             <Alert
               type="info"
               showIcon
-              message={`检测到 ${importRows.length} 个 Topic,将按顺序调用创建接口`}
+              message={`检测到 ${importRows.length} 个 Topic,将通过后端批量导入`}
               description="仅导入可创建字段;CSV 中的 Namespace、Cluster ID 和运行状态列会被忽略。"
             />
           )}
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 2423dc36c..a82101874 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -2,6 +2,7 @@ import { isMockMode } from './dataMode';
 import * as metadataApi from '../api/metadata';
 import type {
   Topic,
+  TopicExportQuery,
   TopicQuery,
   TopicPage,
   BrokerRoute,
@@ -9,11 +10,28 @@ import type {
   TopicConsumerPage,
   SendTopicMessageRequest,
   SendTopicMessageResult,
+  ImportTopicsResult,
 } from '../api/metadata';
 import { topics as mockTopics, topicRoutes, topicConsumers } from 
'../mock/topics';
+import { buildCsv, type CsvColumn } from '../utils/download';
 
 const EXPORT_PAGE_SIZE = 100;
 const MAX_EXPORT_PAGES = 100;
+const TOPIC_EXPORT_COLUMNS: CsvColumn<Topic>[] = [
+  { header: 'Name', value: (topic) => topic.name },
+  { header: 'Namespace', value: (topic) => topic.namespace },
+  { header: 'Type', value: (topic) => topic.type },
+  { header: 'Cluster ID', value: (topic) => topic.clusterId },
+  { header: 'Write Queues', value: (topic) => topic.writeQueues },
+  { header: 'Read Queues', value: (topic) => topic.readQueues },
+  { header: 'Permission', value: (topic) => topic.perm },
+  { header: 'Message Count', value: (topic) => topic.messageCount },
+  { header: 'TPS', value: (topic) => topic.tps },
+  { header: 'Consumer Groups', value: (topic) => topic.consumerGroupCount },
+  { header: 'Remark', value: (topic) => topic.remark },
+  { header: 'Created At', value: (topic) => topic.gmtCreate },
+  { header: 'Updated At', value: (topic) => topic.gmtModified },
+];
 
 const cloneTopic = (topic: Topic): Topic => ({ ...topic });
 const cloneRoutes = (routes: BrokerRoute[]): BrokerRoute[] =>
@@ -37,6 +55,15 @@ function filterMockTopics(params?: TopicQuery): Topic[] {
   return (result as unknown as Topic[]).map(cloneTopic);
 }
 
+function visibleExportTopics(topics: Topic[], params?: TopicExportQuery): 
Topic[] {
+  let result = topics;
+  if (params?.names?.length) {
+    const selectedNames = new Set(params.names);
+    result = result.filter((topic) => selectedNames.has(topic.name));
+  }
+  return [...result].sort((left, right) => 
left.name.localeCompare(right.name));
+}
+
 export async function listTopics(params?: TopicQuery): Promise<Topic[]> {
   if (isMockMode()) {
     return filterMockTopics(params);
@@ -98,6 +125,37 @@ export async function createTopic(data: Partial<Topic>): 
Promise<Topic> {
   return metadataApi.createTopic(data);
 }
 
+export async function importTopics(
+  instanceId: string,
+  topics: Partial<Topic>[],
+): Promise<ImportTopicsResult> {
+  if (isMockMode()) {
+    const imported: Topic[] = [];
+    const failures: ImportTopicsResult['failures'] = [];
+    for (const [index, topic] of topics.entries()) {
+      try {
+        imported.push(await createTopic({ ...topic, instanceId }));
+      } catch (error) {
+        failures.push({
+          index,
+          name: topic.name,
+          message: error instanceof Error ? error.message : '创建失败',
+        });
+      }
+    }
+    return { imported: imported.length, failed: failures.length, topics: 
imported, failures };
+  }
+  return metadataApi.importTopics({ instanceId, topics });
+}
+
+export async function exportTopics(params: TopicExportQuery = {}): 
Promise<string> {
+  if (isMockMode()) {
+    const topics = await listAllTopics(params);
+    return buildCsv(TOPIC_EXPORT_COLUMNS, visibleExportTopics(topics, params));
+  }
+  return metadataApi.exportTopics(params);
+}
+
 export async function updateTopic(data: Partial<Topic>): Promise<Topic> {
   if (isMockMode()) {
     const idx = mockTopics.findIndex((t) => t.name === data.name);

Reply via email to