显示设置
@@ -5434,6 +5494,23 @@ const DataGrid: React.FC
= ({
>
)}
+ {isQueryResultExport && (
+ <>
+
+ }
+ disabled={!canCopyQueryResult}
+ onClick={handleCopyQueryResultCsv}
+ >
+ 复制
+
+
+ } disabled={!canCopyQueryResult} />
+
+ >
+ )}
+
<>
diff --git a/frontend/src/components/dataGridClipboardExport.test.ts b/frontend/src/components/dataGridClipboardExport.test.ts
new file mode 100644
index 0000000..86cb080
--- /dev/null
+++ b/frontend/src/components/dataGridClipboardExport.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildClipboardCsv,
+ buildClipboardJson,
+ buildClipboardMarkdown,
+ pickRowsForClipboard,
+} from './dataGridClipboardExport';
+
+describe('dataGridClipboardExport', () => {
+ it('copies aggregate query rows without treating aggregate columns as table fields', () => {
+ const rows = pickRowsForClipboard({
+ rows: [
+ { __gonavi_row_key__: 0, 'COUNT(*)': 12, 'sum(price)': 99.5 },
+ ],
+ selectedRowKeys: [],
+ columnNames: ['COUNT(*)', 'sum(price)'],
+ rowKeyField: '__gonavi_row_key__',
+ });
+
+ expect(rows).toEqual([{ 'COUNT(*)': 12, 'sum(price)': 99.5 }]);
+ expect(buildClipboardCsv(rows, ['COUNT(*)', 'sum(price)'])).toBe('"COUNT(*)","sum(price)"\n"12","99.5"');
+ expect(buildClipboardMarkdown(rows, ['COUNT(*)', 'sum(price)'])).toBe('| COUNT(*) | sum(price) |\n| --- | --- |\n| 12 | 99.5 |');
+ expect(buildClipboardJson(rows)).toBe('[\n {\n "COUNT(*)": 12,\n "sum(price)": 99.5\n }\n]');
+ });
+
+ it('copies only selected rows when row selection exists', () => {
+ const rows = pickRowsForClipboard({
+ rows: [
+ { __gonavi_row_key__: 'row-1', total: 1 },
+ { __gonavi_row_key__: 'row-2', total: 2 },
+ ],
+ selectedRowKeys: ['row-2'],
+ columnNames: ['total'],
+ rowKeyField: '__gonavi_row_key__',
+ });
+
+ expect(rows).toEqual([{ total: 2 }]);
+ });
+});
diff --git a/frontend/src/components/dataGridClipboardExport.ts b/frontend/src/components/dataGridClipboardExport.ts
new file mode 100644
index 0000000..e8a6188
--- /dev/null
+++ b/frontend/src/components/dataGridClipboardExport.ts
@@ -0,0 +1,83 @@
+type RowKeyToString = (key: any) => string;
+
+const defaultRowKeyToString: RowKeyToString = (key: unknown) => String(key);
+
+const normalizeClipboardValue = (value: unknown): string => {
+ if (value === null || value === undefined) return 'NULL';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+};
+
+const escapeCsvCell = (value: unknown): string => {
+ const text = normalizeClipboardValue(value).replace(/"/g, '""');
+ return `"${text}"`;
+};
+
+const escapeMarkdownCell = (value: unknown): string => (
+ normalizeClipboardValue(value)
+ .replace(/\|/g, '\\|')
+ .replace(/\r?\n/g, ' ')
+);
+
+export const pickRowsForClipboard = ({
+ rows,
+ selectedRowKeys = [],
+ columnNames,
+ rowKeyField,
+ rowKeyToString = defaultRowKeyToString,
+}: {
+ rows: Array>;
+ selectedRowKeys?: unknown[];
+ columnNames: string[];
+ rowKeyField: string;
+ rowKeyToString?: RowKeyToString;
+}): Array> => {
+ if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
+ return [];
+ }
+
+ const selected = new Set((selectedRowKeys || []).map(rowKeyToString));
+ const sourceRows = selected.size > 0
+ ? rows.filter((row) => selected.has(rowKeyToString(row?.[rowKeyField])))
+ : rows;
+
+ return sourceRows.map((row) => {
+ const next: Record = {};
+ columnNames.forEach((columnName) => {
+ if (!columnName || columnName === rowKeyField) return;
+ next[columnName] = row?.[columnName];
+ });
+ return next;
+ });
+};
+
+export const buildClipboardCsv = (rows: Array>, columnNames: string[]): string => {
+ if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
+ return '';
+ }
+ const header = columnNames.map(escapeCsvCell).join(',');
+ const lines = rows.map((row) => columnNames.map((columnName) => escapeCsvCell(row?.[columnName])).join(','));
+ return [header, ...lines].join('\n');
+};
+
+export const buildClipboardMarkdown = (rows: Array>, columnNames: string[]): string => {
+ if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
+ return '';
+ }
+ const header = `| ${columnNames.map(escapeMarkdownCell).join(' | ')} |`;
+ const separator = `| ${columnNames.map(() => '---').join(' | ')} |`;
+ const lines = rows.map((row) => `| ${columnNames.map((columnName) => escapeMarkdownCell(row?.[columnName])).join(' | ')} |`);
+ return [header, separator, ...lines].join('\n');
+};
+
+export const buildClipboardJson = (rows: Array>): string => {
+ if (!Array.isArray(rows) || rows.length === 0) return '';
+ return JSON.stringify(rows, null, 2);
+};