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 3566dba12 feat(web): export runtime diagnostics as CSV (#2019)
3566dba12 is described below

commit 3566dba128f7fb47b975a6ecc70b1f62ce2d3589
Author: coder999o <[email protected]>
AuthorDate: Tue Aug 18 17:10:53 2026 +0800

    feat(web): export runtime diagnostics as CSV (#2019)
---
 .../pages/cluster/__tests__/ClientsPage.test.tsx   | 45 ++++++++++++++
 web/src/pages/cluster/clients.tsx                  | 29 ++++++++-
 web/src/pages/instance/consumer.tsx                | 30 +--------
 web/src/pages/instance/dlq.tsx                     | 29 +++------
 web/src/pages/instance/topic.tsx                   | 30 +--------
 web/src/pages/studio/Producer.tsx                  | 50 ++++++++++++++-
 web/src/pages/studio/__tests__/Producer.test.tsx   | 72 +++++++++++++++++++++-
 web/src/utils/download.test.ts                     | 20 +++++-
 web/src/utils/download.ts                          | 21 +++++++
 9 files changed, 249 insertions(+), 77 deletions(-)

diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index fbe49f029..9e5f8b03b 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -220,6 +220,50 @@ describe('Clients page', () => {
     expect(screen.queryByText('[email protected]:49154')).toBeNull();
   });
 
+  it('exports the currently filtered client connections as CSV', async () => {
+    const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+      expect(blob).toBeInstanceOf(Blob);
+      return 'blob:client-connections';
+    });
+    const revokeObjectURL = vi.fn();
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: revokeObjectURL,
+    });
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
+    const user = userEvent.setup();
+    vi.mocked(connectionsService.listConnections).mockResolvedValue([
+      {
+        ...connection,
+        clientId: '=risky-client',
+        groupOrTopic: 'order-create',
+      },
+      connections[2],
+    ]);
+    renderWithProviders(<ClientsPage />);
+
+    await screen.findByText('=risky-client');
+    await user.type(screen.getByPlaceholderText('搜索 Client ID 或地址'), 'risky');
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    const blob = createObjectURL.mock.calls[0][0] as Blob;
+    await expect(blob.text()).resolves.toBe(
+      [
+        '"Cluster","Client 
ID","Type","Group/Topic","Protocol","Address","Language","Version","Connected 
At","Partial"',
+        
'"ns-prod","\'=risky-client","Producer","order-create","gRPC","10.0.1.12:49152","Java","5.0.7","2026-07-01
 08:30:00","false"',
+      ].join('\n'),
+    );
+    expect(
+      document.querySelector('a[download^="rocketmq-client-connections-"]'),
+    ).not.toBeInTheDocument();
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:client-connections');
+  });
+
   it('renders empty distributions when no connections are available', async () 
=> {
     vi.mocked(connectionsService.listConnections).mockResolvedValue([]);
     renderWithProviders(<ClientsPage />);
@@ -235,6 +279,7 @@ describe('Clients page', () => {
     expect(
       
within(screen.getByTestId('language-version-distribution')).getByText('暂无数据'),
     ).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /导出/ })).toBeDisabled();
   });
 
   it('surfaces unavailable provider errors from the client API', async () => {
diff --git a/web/src/pages/cluster/clients.tsx 
b/web/src/pages/cluster/clients.tsx
index 2f0347773..278bef5cb 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -32,7 +32,7 @@ import {
   Typography,
   theme,
 } from 'antd';
-import { Eye, MagnifyingGlass } from '@phosphor-icons/react';
+import { DownloadSimple, Eye, MagnifyingGlass } from '@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
 
 import PageHeader from '../../components/PageHeader';
@@ -42,6 +42,7 @@ import { listConnections } from 
'../../services/connectionsService';
 import { listRegistryClusters } from '../../services/clusterService';
 import type { ClusterInfo } from '../../api/cluster';
 import { formatDateTime } from '../../utils/format';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const { Text } = Typography;
 const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
@@ -69,6 +70,19 @@ const languageConfig: Record<string, { color: string; label: 
string }> = {
   PHP: { color: 'gold', label: 'PHP' },
 };
 
+const CLIENT_CONNECTION_EXPORT_COLUMNS: CsvColumn<ClientConnection>[] = [
+  { header: 'Cluster', value: (connection) => connection.clusterName },
+  { header: 'Client ID', value: (connection) => connection.clientId },
+  { header: 'Type', value: (connection) => connection.type },
+  { header: 'Group/Topic', value: (connection) => connection.groupOrTopic },
+  { header: 'Protocol', value: (connection) => connection.protocol },
+  { header: 'Address', value: (connection) => connection.address },
+  { header: 'Language', value: (connection) => connection.language },
+  { header: 'Version', value: (connection) => connection.version },
+  { header: 'Connected At', value: (connection) => connection.connectedAt },
+  { header: 'Partial', value: (connection) => (connection.partial ? 'true' : 
'false') },
+];
+
 const countBy = (values: string[]) =>
   [
     ...values.reduce(
@@ -249,6 +263,12 @@ const ClientsPage = () => {
     );
   }, [clusterConnections, search]);
 
+  const handleExport = () => {
+    const filename = `rocketmq-client-connections-${new 
Date().toISOString().slice(0, 10)}.csv`;
+    const csv = buildCsv(CLIENT_CONNECTION_EXPORT_COLUMNS, filtered);
+    downloadCsv(filename, csv);
+  };
+
   /* ═══════════════════════════════════════════
      Table Columns (with built-in filters)
      ═══════════════════════════════════════════ */
@@ -454,6 +474,13 @@ const ClientsPage = () => {
             prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
           />
         </Space>
+        <Button
+          icon={<DownloadSimple size={16} />}
+          disabled={filtered.length === 0}
+          onClick={handleExport}
+        >
+          {t('common.export')}
+        </Button>
       </Flex>
 
       <Flex
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index d37625c20..62e57c173 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -86,6 +86,7 @@ import {
   validateConsumerGroupCsvImport,
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const { Text } = Typography;
 
@@ -121,7 +122,7 @@ const formatDelay = (totalSeconds: number): string => {
   return parts.length > 0 ? parts.join('') : '0秒';
 };
 
-const GROUP_EXPORT_COLUMNS: Array<{ header: string; value: (group: 
ConsumerGroup) => unknown }> = [
+const GROUP_EXPORT_COLUMNS: CsvColumn<ConsumerGroup>[] = [
   { header: 'Name', value: (group) => group.name },
   { header: 'Namespace', value: (group) => group.namespace },
   { header: 'Cluster ID', value: (group) => group.clusterId },
@@ -138,32 +139,7 @@ const GROUP_EXPORT_COLUMNS: Array<{ header: string; value: 
(group: ConsumerGroup
   { header: 'Updated At', value: (group) => group.gmtModified },
 ];
 
-const escapeCsvCell = (value: unknown) => {
-  const text = value == null ? '' : String(value);
-  const formulaSafeText = /^[=+\-@\t\r\n]/.test(text) ? `'${text}` : text;
-  return `"${formulaSafeText.replace(/"/g, '""')}"`;
-};
-
-const buildConsumerGroupCsv = (groups: ConsumerGroup[]) =>
-  [
-    GROUP_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.header)).join(','),
-    ...groups.map((group) =>
-      GROUP_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.value(group))).join(','),
-    ),
-  ].join('\n');
-
-const downloadCsv = (filename: string, csv: string) => {
-  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
-  const url = URL.createObjectURL(blob);
-  const anchor = document.createElement('a');
-  anchor.href = url;
-  anchor.download = filename;
-  anchor.style.display = 'none';
-  document.body.appendChild(anchor);
-  anchor.click();
-  anchor.remove();
-  URL.revokeObjectURL(url);
-};
+const buildConsumerGroupCsv = (groups: ConsumerGroup[]) => 
buildCsv(GROUP_EXPORT_COLUMNS, groups);
 
 const normalizedConsistency = (value?: string | null): string => 
value?.trim().toLowerCase() ?? '';
 
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 8be135c69..583b387f0 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -39,7 +39,7 @@ import { useLang } from '../../i18n/LangContext';
 import type { DLQGroup } from '../../api/message';
 import { listDLQGroups, resendDLQ } from '../../services/messageService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
-import { downloadBlob } from '../../utils/download';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const { Text } = Typography;
 const { RangePicker } = DatePicker;
@@ -77,26 +77,17 @@ const formatDateTime = (iso?: string | null): string => {
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
 };
 
-const escapeCSVValue = (value: string) => {
-  const safeValue = /^[=+\-@\t\r\n]/.test(value) ? `'${value}` : value;
-  return `"${safeValue.replace(/"/g, '""')}"`;
-};
+const DLQ_EXPORT_COLUMNS: CsvColumn<DLQGroup>[] = [
+  { header: 'Group Name', value: (group) => group.groupName },
+  { header: 'DLQ Topic', value: (group) => group.dlqTopic },
+  { header: 'Message Count', value: (group) => group.messageCount },
+  { header: 'Retry Count', value: (group) => group.retryCount },
+  { header: 'Status', value: (group) => group.status },
+  { header: 'Last Enqueue Time', value: (group) => group.lastEnqueueTime },
+];
 
 const exportDLQGroups = (groups: DLQGroup[], filename: string) => {
-  const rows = [
-    ['Group Name', 'DLQ Topic', 'Message Count', 'Retry Count', 'Status', 
'Last Enqueue Time'],
-    ...groups.map((group) => [
-      group.groupName,
-      group.dlqTopic,
-      String(group.messageCount),
-      String(group.retryCount),
-      group.status,
-      group.lastEnqueueTime || '',
-    ]),
-  ];
-  const csv = rows.map((row) => row.map(escapeCSVValue).join(',')).join('\n');
-  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
-  downloadBlob(blob, filename);
+  downloadCsv(filename, buildCsv(DLQ_EXPORT_COLUMNS, groups));
 };
 
 /* ═══════════════════════════════════════════
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 83be3de7c..63b8b29a7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -75,6 +75,7 @@ import {
   validateTopicCsvImport,
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const { Text } = Typography;
 
@@ -113,7 +114,7 @@ const TOPIC_TYPE_CARDS = [
 // ─── Perm label ───────────────────────────────────────────────────
 const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
 
-const TOPIC_EXPORT_COLUMNS: Array<{ header: string; value: (topic: Topic) => 
unknown }> = [
+const TOPIC_EXPORT_COLUMNS: CsvColumn<Topic>[] = [
   { header: 'Name', value: (topic) => topic.name },
   { header: 'Namespace', value: (topic) => topic.namespace },
   { header: 'Type', value: (topic) => topic.type },
@@ -129,32 +130,7 @@ const TOPIC_EXPORT_COLUMNS: Array<{ header: string; value: 
(topic: Topic) => unk
   { header: 'Updated At', value: (topic) => topic.gmtModified },
 ];
 
-const escapeCsvCell = (value: unknown) => {
-  const text = value == null ? '' : String(value);
-  const formulaSafeText = /^[=+\-@\t\r\n]/.test(text) ? `'${text}` : text;
-  return `"${formulaSafeText.replace(/"/g, '""')}"`;
-};
-
-const buildTopicCsv = (topics: Topic[]) =>
-  [
-    TOPIC_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.header)).join(','),
-    ...topics.map((topic) =>
-      TOPIC_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.value(topic))).join(','),
-    ),
-  ].join('\n');
-
-const downloadCsv = (filename: string, csv: string) => {
-  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
-  const url = URL.createObjectURL(blob);
-  const anchor = document.createElement('a');
-  anchor.href = url;
-  anchor.download = filename;
-  anchor.style.display = 'none';
-  document.body.appendChild(anchor);
-  anchor.click();
-  anchor.remove();
-  URL.revokeObjectURL(url);
-};
+const buildTopicCsv = (topics: Topic[]) => buildCsv(TOPIC_EXPORT_COLUMNS, 
topics);
 
 // ─── Random message body generators ──────────────────────────────
 const randomOrderBody = () =>
diff --git a/web/src/pages/studio/Producer.tsx 
b/web/src/pages/studio/Producer.tsx
index a385eeb9b..5a6b661de 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -29,7 +29,7 @@ import {
   Table,
   Tag,
 } from 'antd';
-import { MagnifyingGlass } from '@phosphor-icons/react';
+import { DownloadSimple, MagnifyingGlass } from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
 import {
   fetchProducerGroups,
@@ -42,6 +42,7 @@ import {
 } from '../../api/producer';
 import type { Instance } from '../../api/instance';
 import { listInstances } from '../../services/instanceService';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const readinessConfig: Record<ProducerReadiness, { color: string; type: 
'success' | 'warning' }> = {
   READY: { color: 'success', type: 'success' },
@@ -49,6 +50,26 @@ const readinessConfig: Record<ProducerReadiness, { color: 
string; type: 'success
   UNAVAILABLE: { color: 'error', type: 'warning' },
 };
 
+interface ProducerConnectionExportRow extends ProducerConnection {
+  instanceId: string;
+  topic?: string;
+  producerGroup?: string;
+  readiness?: ProducerReadiness;
+  warnings: string;
+}
+
+const PRODUCER_CONNECTION_EXPORT_COLUMNS: 
CsvColumn<ProducerConnectionExportRow>[] = [
+  { header: 'Instance ID', value: (connection) => connection.instanceId },
+  { header: 'Topic', value: (connection) => connection.topic },
+  { header: 'Producer Group', value: (connection) => connection.producerGroup 
},
+  { header: 'Readiness', value: (connection) => connection.readiness },
+  { header: 'Warnings', value: (connection) => connection.warnings },
+  { header: 'Client ID', value: (connection) => connection.clientId },
+  { header: 'Address', value: (connection) => connection.clientAddr },
+  { header: 'Language', value: (connection) => connection.language },
+  { header: 'Version', value: (connection) => connection.versionDesc },
+];
+
 const ProducerPage = () => {
   const [form] = Form.useForm();
   const [topicList, setTopicList] = useState<string[]>([]);
@@ -224,6 +245,24 @@ const ProducerPage = () => {
       </Flex>
     );
 
+  const handleExport = () => {
+    const { selectedTopic, producerGroup } = form.getFieldsValue([
+      'selectedTopic',
+      'producerGroup',
+    ]) as { selectedTopic?: string; producerGroup?: string };
+    const warnings = connectionSummary?.warnings.join(';') ?? '';
+    const rows = connectionList.map((connection) => ({
+      ...connection,
+      instanceId: selectedInstanceId ?? '',
+      topic: selectedTopic,
+      producerGroup,
+      readiness: connectionSummary?.readiness,
+      warnings,
+    }));
+    const filename = `rocketmq-producer-connections-${new 
Date().toISOString().slice(0, 10)}.csv`;
+    const csv = buildCsv(PRODUCER_CONNECTION_EXPORT_COLUMNS, rows);
+    downloadCsv(filename, csv);
+  };
   return (
     <div style={{ padding: 0 }}>
       <div
@@ -296,6 +335,15 @@ const ProducerPage = () => {
           </Form.Item>
         </Form>
 
+        <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+          <Button
+            icon={<DownloadSimple size={16} />}
+            disabled={connectionList.length === 0}
+            onClick={handleExport}
+          >
+            {t('common.export')}
+          </Button>
+        </Flex>
         {connectionSummary && (
           <div style={{ marginBottom: 20 }}>
             <Alert
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx 
b/web/src/pages/studio/__tests__/Producer.test.tsx
index ddc5e8258..ae70c2209 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 import { fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
@@ -101,6 +101,10 @@ describe('ProducerPage', () => {
     vi.mocked(queryProducerConnection).mockResolvedValue(producerResult([]));
   });
 
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
   it('loads topic options after mount', async () => {
     renderWithProviders(<ProducerPage />);
 
@@ -235,6 +239,72 @@ describe('ProducerPage', () => {
     expect(screen.getByText('JAVA: 2')).toBeInTheDocument();
   });
 
+  it('exports the current producer connection diagnostics as CSV', async () => 
{
+    const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+      expect(blob).toBeInstanceOf(Blob);
+      return 'blob:producer-connections';
+    });
+    const revokeObjectURL = vi.fn();
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: revokeObjectURL,
+    });
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
+    const user = userEvent.setup();
+    vi.mocked(queryProducerConnection).mockResolvedValue({
+      connectionSet: [
+        {
+          clientId: '@producer-a',
+          clientAddr: '192.168.1.10',
+          language: 'JAVA',
+          versionDesc: '5.1.0',
+        },
+      ],
+      summary: {
+        totalConnections: 1,
+        uniqueClientCount: 1,
+        uniqueAddressCount: 1,
+        uniqueLanguageCount: 1,
+        uniqueVersionCount: 1,
+        languages: [{ value: 'JAVA', count: 1 }],
+        versions: [{ value: '5.1.0', count: 1 }],
+        duplicateClientIds: [],
+        warnings: ['INCOMPLETE_CLIENT_METADATA'],
+        readiness: 'WARNING',
+      },
+    });
+    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');
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    expect(await screen.findByText('@producer-a')).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    const blob = createObjectURL.mock.calls[0][0] as Blob;
+    await expect(blob.text()).resolves.toBe(
+      [
+        '"Instance ID","Topic","Producer Group","Readiness","Warnings","Client 
ID","Address","Language","Version"',
+        
'"instance-1","order-events","order-producer","WARNING","INCOMPLETE_CLIENT_METADATA","\'@producer-a","192.168.1.10","JAVA","5.1.0"',
+      ].join('\n'),
+    );
+    expect(
+      document.querySelector('a[download^="rocketmq-producer-connections-"]'),
+    ).not.toBeInTheDocument();
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:producer-connections');
+  });
+
   it('keeps manual producer group queries available when suggestions fail', 
async () => {
     vi.mocked(fetchProducerGroups).mockRejectedValue(new Error('broker 
unavailable'));
     const user = userEvent.setup();
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 16effd2d5..11c2af54d 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -16,7 +16,25 @@
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest';
-import { downloadBlob } from './download';
+import { buildCsv, downloadBlob } from './download';
+describe('buildCsv', () => {
+  it('escapes quotes, empty values, and spreadsheet formulas', () => {
+    const csv = buildCsv(
+      [
+        { header: 'Name', value: (row: { name: string }) => row.name },
+        { header: 'Remark', value: (row: { remark?: string | null }) => 
row.remark },
+      ],
+      [
+        { name: '=SUM(A1:A2)', remark: 'hello, "mq"' },
+        { name: '\nline-feed', remark: null },
+      ],
+    );
+
+    expect(csv).toBe(
+      ['"Name","Remark"', '"\'=SUM(A1:A2)","hello, ""mq"""', 
'"\'\nline-feed",""'].join('\n'),
+    );
+  });
+});
 
 describe('downloadBlob', () => {
   afterEach(() => {
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index caaf19750..d54ca6380 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -26,3 +26,24 @@ export const downloadBlob = (blob: Blob, filename: string) 
=> {
   anchor.remove();
   URL.revokeObjectURL(url);
 };
+
+export interface CsvColumn<T> {
+  header: string;
+  value: (row: T) => unknown;
+}
+
+export const escapeCsvCell = (value: unknown) => {
+  const text = value == null ? '' : String(value);
+  const formulaSafeText = /^[=+\-@\t\r\n]/.test(text) ? `'${text}` : text;
+  return `"${formulaSafeText.replace(/"/g, '""')}"`;
+};
+
+export const buildCsv = <T>(columns: CsvColumn<T>[], rows: T[]) =>
+  [
+    columns.map((column) => escapeCsvCell(column.header)).join(','),
+    ...rows.map((row) => columns.map((column) => 
escapeCsvCell(column.value(row))).join(',')),
+  ].join('\n');
+
+export const downloadCsv = (filename: string, csv: string) => {
+  downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
+};

Reply via email to