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 8043cf785 fix(web): frontend input and data fidelity (#2200)
8043cf785 is described below

commit 8043cf785c57be9e2b6f29c88e32c1720412eeaa
Author: shown <[email protected]>
AuthorDate: Wed Aug 19 11:19:14 2026 +0800

    fix(web): frontend input and data fidelity (#2200)
    
    * fix(web): preserve AI prompt submission integrity
    
    * fix(web): preserve CSV import round-trip semantics
    
    * fix(web): normalize producer connection summary identity
    
    * fix(web): validate pasted message properties
    
    * fix(lite-topic): clear stale quota data
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * fix(producer): guard query request lifecycle
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    ---------
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 web/src/api/producer.test.ts                       | 22 ++++++++
 web/src/api/producer.ts                            |  8 ++-
 web/src/pages/ai/__tests__/AiPage.test.tsx         | 42 ++++++++++++++-
 web/src/pages/ai/index.tsx                         |  6 ++-
 web/src/pages/home/__tests__/HomePage.test.tsx     | 13 ++++-
 web/src/pages/home/index.tsx                       |  1 +
 .../pages/instance/__tests__/TopicPage.test.tsx    | 15 ++++++
 web/src/pages/instance/topic.tsx                   | 21 +++-----
 web/src/pages/studio/LiteTopic.tsx                 |  3 ++
 web/src/pages/studio/Producer.tsx                  |  9 ++++
 web/src/pages/studio/__tests__/LiteTopic.test.tsx  | 21 ++++++++
 web/src/pages/studio/__tests__/Producer.test.tsx   | 62 ++++++++++++++++++++++
 web/src/utils/messageProperties.ts                 | 45 ++++++++++++++++
 web/src/utils/resourceCsvImport.test.ts            | 17 ++++++
 web/src/utils/resourceCsvImport.ts                 |  4 +-
 15 files changed, 268 insertions(+), 21 deletions(-)

diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index 26b39f9bf..54cbfabce 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -169,4 +169,26 @@ describe('Producer API', () => {
     expect(result.duplicateClientIds).toEqual(['producer-a']);
     expect(result.warnings).toEqual(['DUPLICATE_CLIENT_ID', 
'MIXED_CLIENT_VERSION']);
   });
+
+  it('normalizes client identifiers and addresses consistently in summary 
counts', () => {
+    const result = buildProducerConnectionSummary([
+      {
+        clientId: 'producer-a',
+        clientAddr: '10.0.0.1',
+        language: 'Java',
+        versionDesc: '5.1.0',
+      },
+      {
+        clientId: ' producer-a ',
+        clientAddr: ' 10.0.0.1 ',
+        language: 'Java',
+        versionDesc: '5.1.0',
+      },
+    ]);
+
+    expect(result.uniqueClientCount).toBe(1);
+    expect(result.uniqueAddressCount).toBe(1);
+    expect(result.duplicateClientIds).toEqual(['producer-a']);
+    expect(result.warnings).toContain('DUPLICATE_CLIENT_ID');
+  });
 });
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
index 5249a8706..26b458853 100644
--- a/web/src/api/producer.ts
+++ b/web/src/api/producer.ts
@@ -76,7 +76,13 @@ const normalizeDimension = (value?: string | null) => 
(hasText(value) ? value!.t
 const countDistinct = (
   connections: ProducerConnection[],
   extractor: (connection: ProducerConnection) => string,
-) => new Set(connections.map(extractor).filter(hasText)).size;
+) =>
+  new Set(
+    connections
+      .map(extractor)
+      .filter(hasText)
+      .map((value) => value.trim()),
+  ).size;
 
 const distribution = (
   connections: ProducerConnection[],
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 863318e53..5c782c6f4 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -288,6 +288,36 @@ describe('AiPage tool runner', () => {
     await waitFor(() => expect(screen.queryByRole('button', { name: '停止' 
})).not.toBeInTheDocument());
   });
 
+  it('does not send while an input method composition is being confirmed', 
async () => {
+    renderPage();
+    const input = await screen.findByPlaceholderText(
+      '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+    );
+    await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+
+    fireEvent.change(input, { target: { value: '检查集群状态' } });
+    fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
+
+    expect(chatStream).not.toHaveBeenCalled();
+  });
+
+  it('deduplicates prompt submissions before loading state is rendered', async 
() => {
+    vi.mocked(chatStream).mockReturnValue(new Promise(() => {}));
+    renderPage();
+    const input = await screen.findByPlaceholderText(
+      '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+    );
+    await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+    fireEvent.change(input, { target: { value: '检查集群状态' } });
+
+    await act(async () => {
+      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', 
bubbles: true }));
+      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', 
bubbles: true }));
+    });
+
+    expect(chatStream).toHaveBeenCalledTimes(1);
+  });
+
   it('loads the catalog, creates a schema template, and renders structured 
output', async () => {
     const user = userEvent.setup();
     vi.mocked(executeTool).mockResolvedValue({
@@ -325,8 +355,16 @@ describe('AiPage tool runner', () => {
     let resolveOld!: (value: typeof oldTools) => void;
     let resolveLatest!: (value: typeof latestTools) => void;
     vi.mocked(listTools)
-      .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
-      .mockReturnValueOnce(new Promise((resolve) => { resolveLatest = resolve; 
}));
+      .mockReturnValueOnce(
+        new Promise((resolve) => {
+          resolveOld = resolve;
+        }),
+      )
+      .mockReturnValueOnce(
+        new Promise((resolve) => {
+          resolveLatest = resolve;
+        }),
+      );
     const user = userEvent.setup();
     renderPage();
 
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 4ec8da584..3e6c3f577 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -462,6 +462,7 @@ const AiPage = () => {
   const streamRequestIdRef = useRef(0);
   const previousChatModeRef = useRef(chatMode);
   const conversationIdRef = useRef<string | 
null>(history.activeConversationId);
+  const chatInFlightRef = useRef(false);
   const toolLoadRequestRef = useRef(0);
   const consumedDraftRef = useRef(false);
   const pendingAutoSendRef = useRef<{
@@ -591,11 +592,12 @@ const AiPage = () => {
     ) => {
       const text = (textOverride ?? inputValue).trim();
       const model = modelOverride ?? selectedModel;
-      if (!text || loading) return;
+      if (!text || loading || chatInFlightRef.current) return;
       if (!llmReady) {
         message.warning(t('ai.providerRequired'));
         return;
       }
+      chatInFlightRef.current = true;
 
       if (!conversationIdRef.current) {
         conversationIdRef.current = newConversationId();
@@ -675,6 +677,7 @@ const AiPage = () => {
           message.error(errorMessage);
         }
       } finally {
+        chatInFlightRef.current = false;
         if (abortControllerRef.current === controller) 
abortControllerRef.current = null;
         updateMessages(chatMode, conversationId, (prev) =>
           prev.map((item) => (item.id === responseId ? { ...item, pending: 
false } : item)),
@@ -700,6 +703,7 @@ const AiPage = () => {
 
   const handleKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      if (e.nativeEvent.isComposing) return;
       if (e.key === 'Enter' && !e.shiftKey) {
         e.preventDefault();
         handleSend();
diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx 
b/web/src/pages/home/__tests__/HomePage.test.tsx
index 035e8ae7e..c2fe44391 100644
--- a/web/src/pages/home/__tests__/HomePage.test.tsx
+++ b/web/src/pages/home/__tests__/HomePage.test.tsx
@@ -16,7 +16,7 @@
  */
 
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
@@ -111,4 +111,15 @@ describe('HomePage LLM models', () => {
       });
     });
   });
+
+  it('does not submit while an input method composition is being confirmed', 
async () => {
+    renderHome();
+    await screen.findByText('qwen3.8-max');
+    const input = screen.getByPlaceholderText('向 RocketMQ Bot 提问,全程加密、安全、可信');
+
+    fireEvent.change(input, { target: { value: '查看集群状态' } });
+    fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
+
+    expect(navigateMock).not.toHaveBeenCalled();
+  });
 });
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 90b32dacb..21b081c2c 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -186,6 +186,7 @@ const HomePage = () => {
 
   /* ─── Keyboard shortcut: Enter to send ─── */
   const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.nativeEvent.isComposing) return;
     if (e.key === 'Enter' && !e.shiftKey) {
       e.preventDefault();
       handlePromptSubmit();
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index d4562a259..6a61755f1 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -22,6 +22,7 @@ import { MemoryRouter, Route, Routes } from 
'react-router-dom';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import type { Topic } from '../../../api/metadata';
+import { parseMessageProperties } from '../../../utils/messageProperties';
 import TopicPage from '../topic';
 
 const topicServiceMocks = vi.hoisted(() => ({
@@ -478,6 +479,20 @@ describe('TopicPage', () => {
     expect(screen.getByRole('button', { name: /创建 Topic/ })).toBeDisabled();
   });
 
+  it('rejects malformed or duplicate batch message properties without 
sending', () => {
+    
expect(parseMessageProperties('traceId=abc\ntenant\ntraceId=duplicate')).toEqual({
+      properties: { traceId: 'abc' },
+      errors: ['“tenant”应使用 key=value 格式', '属性名“traceId”重复'],
+    });
+  });
+
+  it('preserves equals signs in valid batch message property values', () => {
+    expect(parseMessageProperties('signature=part-a=part-b')).toEqual({
+      properties: { signature: 'part-a=part-b' },
+      errors: [],
+    });
+  });
+
   it('renders unavailable Topic consumer metrics distinctly from zero', async 
() => {
     const user = userEvent.setup();
     mockTopicsList([buildTopics(1)[0]]);
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index b6eb6575b..49ecfc123 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -77,6 +77,7 @@ import {
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
 import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { parseMessageProperties } from '../../utils/messageProperties';
 
 const { Text } = Typography;
 
@@ -261,19 +262,6 @@ const RANDOM_BODY_GENERATORS = [
 // ─── Format helpers ───────────────────────────────────────────────
 const formatNumber = (n: number) => n.toLocaleString('zh-CN');
 
-// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
-const parsePropsText = (text: string): Record<string, string> => {
-  const props: Record<string, string> = {};
-  for (const line of text.split(/[\n,]+/)) {
-    const trimmed = line.trim();
-    if (!trimmed) continue;
-    const eqIndex = trimmed.indexOf('=');
-    if (eqIndex <= 0) continue;
-    const key = trimmed.slice(0, eqIndex).trim();
-    if (key) props[key] = trimmed.slice(eqIndex + 1).trim();
-  }
-  return props;
-};
 const formatDateTime = (iso?: string): string => {
   if (!iso) return '-';
   const d = new Date(iso);
@@ -946,7 +934,12 @@ const TopicPage = () => {
       // Build properties: batch-paste text mode or key-value form rows
       let props: Record<string, string> = {};
       if (propsMode === 'text') {
-        props = parsePropsText(values.propsText || '');
+        const parsed = parseMessageProperties(values.propsText || '');
+        if (parsed.errors.length > 0) {
+          message.error(`消息属性格式错误:${parsed.errors.join(';')}`);
+          return;
+        }
+        props = parsed.properties;
       } else if (values.properties && Array.isArray(values.properties)) {
         values.properties.forEach((p: { key?: string; value?: string }) => {
           if (p.key) props[p.key] = p.value || '';
diff --git a/web/src/pages/studio/LiteTopic.tsx 
b/web/src/pages/studio/LiteTopic.tsx
index 0035446ab..7692ad95d 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -166,6 +166,9 @@ const LiteTopicPage: React.FC = () => {
 
       if (quotaResult.status === 'fulfilled') {
         setQuota(quotaResult.value);
+      } else {
+        setQuota(null);
+        
messageRef.current.warning(translationRef.current('liteTopic.fetchQuotaFailed'));
       }
 
       if (listResult.status === 'fulfilled') {
diff --git a/web/src/pages/studio/Producer.tsx 
b/web/src/pages/studio/Producer.tsx
index 68cff6813..9ac04261a 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -85,6 +85,7 @@ const ProducerPage = () => {
   const { message } = App.useApp();
   const fetchTopicFailedMessage = t('producer.fetchTopicFailed');
   const queryRequestIdRef = useRef(0);
+  const queryInFlightRef = useRef<number | null>(null);
 
   useEffect(() => {
     let cancelled = false;
@@ -113,6 +114,7 @@ const ProducerPage = () => {
 
   const handleInstanceChange = (instanceId: string) => {
     queryRequestIdRef.current += 1;
+    queryInFlightRef.current = null;
     setSelectedInstanceId(instanceId);
     setTopicList([]);
     setProducerGroups([]);
@@ -178,11 +180,15 @@ const ProducerPage = () => {
   }, [selectedInstanceId]);
 
   const onFinish = async (values: { selectedTopic: string; producerGroup: 
string }) => {
+    if (queryInFlightRef.current !== null) return;
     if (!selectedInstanceId) {
       message.error('Select an instance before querying producer 
connections.');
       return;
     }
     const requestId = ++queryRequestIdRef.current;
+    queryInFlightRef.current = requestId;
+    setConnectionList([]);
+    setConnectionSummary(null);
     setLoading(true);
     try {
       const result = await queryProducerConnection(
@@ -202,6 +208,9 @@ const ProducerPage = () => {
         message.error(t('producer.fetchConnectionFailed'));
       }
     } finally {
+      if (queryInFlightRef.current === requestId) {
+        queryInFlightRef.current = null;
+      }
       if (requestId === queryRequestIdRef.current) {
         setLoading(false);
       }
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx 
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index d2b88af4e..6deaea9ae 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -465,4 +465,25 @@ describe('LiteTopic Page', () => {
     expect(screen.queryByText('old-*')).not.toBeInTheDocument();
     expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
   });
+
+  it('clears stale quota while preserving a successfully refreshed topic 
list', async () => {
+    apiMocks.queryLiteTopicQuota
+      .mockResolvedValueOnce(createQuota(90))
+      .mockRejectedValueOnce(new Error('quota unavailable'));
+    apiMocks.queryLiteTopicList
+      .mockResolvedValueOnce([{ namespace: 'default', topicPattern: 'old-*' }])
+      .mockResolvedValueOnce([{ namespace: 'default', topicPattern: 'fresh-*' 
}]);
+    const user = userEvent.setup();
+    renderPage();
+
+    expect(await screen.findByText('old-*')).toBeInTheDocument();
+    expect(screen.getByText('90 / 100')).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /刷新/ }));
+
+    expect(await screen.findByText('fresh-*')).toBeInTheDocument();
+    expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+    expect(await screen.findByText('获取配额信息失败')).toBeInTheDocument();
+  });
 });
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx 
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 3fabf951f..1167ad269 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -422,4 +422,66 @@ describe('ProducerPage', () => {
     
expect(within(container).queryByText('producer-1')).not.toBeInTheDocument();
     
expect(within(container).queryByText('order-events')).not.toBeInTheDocument();
   });
+
+  it('clears stale results when a new producer query fails', async () => {
+    const user = userEvent.setup();
+    vi.mocked(queryProducerConnection)
+      .mockResolvedValueOnce(
+        producerResult([
+          {
+            clientId: 'producer-1',
+            clientAddr: '192.168.1.10',
+            language: 'JAVA',
+            versionDesc: '5.1.0',
+          },
+        ]),
+      )
+      .mockRejectedValueOnce(new Error('broker unavailable'));
+    renderWithProviders(<ProducerPage />);
+
+    await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+    const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+    fireEvent.mouseDown(topicSelect.parentElement!);
+    await user.click(
+      await screen.findByText('order-events', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.type(groupInput, 'order-producer');
+    const search = screen.getByRole('button', { name: /搜索/ });
+    await user.click(search);
+    expect(await screen.findByText('producer-1')).toBeInTheDocument();
+
+    await user.click(search);
+
+    await waitFor(() => 
expect(queryProducerConnection).toHaveBeenCalledTimes(2));
+    await waitFor(() => 
expect(screen.queryByText('producer-1')).not.toBeInTheDocument());
+    expect(screen.queryByText('生产者连接健康')).not.toBeInTheDocument();
+  });
+
+  it('ignores duplicate producer queries while the first request is pending', 
async () => {
+    const user = userEvent.setup();
+    let resolveQuery: ((value: ProducerConnectionResult) => void) | undefined;
+    vi.mocked(queryProducerConnection).mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          resolveQuery = resolve;
+        }),
+    );
+    renderWithProviders(<ProducerPage />);
+
+    await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+    const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+    fireEvent.mouseDown(topicSelect.parentElement!);
+    await user.click(
+      await screen.findByText('order-events', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.type(groupInput, 'order-producer');
+    const search = screen.getByRole('button', { name: /搜索/ });
+    await user.click(search);
+    await waitFor(() => 
expect(queryProducerConnection).toHaveBeenCalledTimes(1));
+    fireEvent.click(search);
+
+    expect(queryProducerConnection).toHaveBeenCalledTimes(1);
+    resolveQuery?.(producerResult([]));
+    await waitFor(() => expect(search).not.toHaveClass('ant-btn-loading'));
+  });
 });
diff --git a/web/src/utils/messageProperties.ts 
b/web/src/utils/messageProperties.ts
new file mode 100644
index 000000000..552b0d07c
--- /dev/null
+++ b/web/src/utils/messageProperties.ts
@@ -0,0 +1,45 @@
+/*
+ * 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.
+ */
+
+interface ParsedProperties {
+  properties: Record<string, string>;
+  errors: string[];
+}
+
+// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
+export const parseMessageProperties = (text: string): ParsedProperties => {
+  const properties: Record<string, string> = {};
+  const errors: string[] = [];
+  for (const line of text.split(/[\n,]+/)) {
+    const trimmed = line.trim();
+    if (!trimmed) continue;
+    const eqIndex = trimmed.indexOf('=');
+    if (eqIndex <= 0) {
+      errors.push(`“${trimmed}”应使用 key=value 格式`);
+      continue;
+    }
+    const key = trimmed.slice(0, eqIndex).trim();
+    if (!key) {
+      errors.push(`“${trimmed}”的属性名不能为空`);
+    } else if (Object.prototype.hasOwnProperty.call(properties, key)) {
+      errors.push(`属性名“${key}”重复`);
+    } else {
+      properties[key] = trimmed.slice(eqIndex + 1).trim();
+    }
+  }
+  return { properties, errors };
+};
diff --git a/web/src/utils/resourceCsvImport.test.ts 
b/web/src/utils/resourceCsvImport.test.ts
index 329d8dfc1..4621e38df 100644
--- a/web/src/utils/resourceCsvImport.test.ts
+++ b/web/src/utils/resourceCsvImport.test.ts
@@ -67,6 +67,23 @@ describe('resourceCsvImport', () => {
     });
   });
 
+  it.each(['\t', '\r', '\n'])('restores exported control-prefixed cells for 
%j', (prefix) => {
+    const records = parseCsvTable(`"Name","Remark"\n"'${prefix}topic-a","ok"`);
+
+    expect(records[0].values.Name).toBe('topic-a');
+  });
+
+  it('tracks lone carriage returns inside quoted fields when reporting row 
errors', () => {
+    const content = [
+      '"Name","Remark"',
+      '"topic-a","line1\rline2"',
+      '"topic-b","ok"',
+      '"topic-c","too","many"',
+    ].join('\r\n');
+
+    expect(() => parseCsvTable(content)).toThrow('第 5 行字段数超过表头字段数');
+  });
+
   it('validates topic fields and duplicate names before import calls', () => {
     const records = parseCsvTable(
       [
diff --git a/web/src/utils/resourceCsvImport.ts 
b/web/src/utils/resourceCsvImport.ts
index a94385f48..19a6d10f7 100644
--- a/web/src/utils/resourceCsvImport.ts
+++ b/web/src/utils/resourceCsvImport.ts
@@ -43,7 +43,7 @@ interface ParsedCsvRow {
   cells: string[];
 }
 
-const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@])/;
+const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@\t\r\n])/;
 const TOPIC_NAME_PATTERN = /^[a-zA-Z0-9_\-/*]+$/;
 const GROUP_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
 
@@ -122,7 +122,7 @@ const readCsvRows = (content: string): ParsedCsvRow[] => {
           quoteJustClosed = true;
         }
       } else {
-        if (char === '\n') lineNumber += 1;
+        if (char === '\n' || (char === '\r' && next !== '\n')) lineNumber += 1;
         cell += char;
       }
       continue;

Reply via email to