🐛 fix(DataGrid): 修复聚合查询结果无法复制的问题

- 为查询结果页新增独立复制入口
- 支持 CSV、JSON、Markdown 复制当前结果集
- 补充聚合列复制与按钮可点击回归测试
This commit is contained in:
Syngnat
2026-05-06 21:47:16 +08:00
parent da9a76715a
commit 1616ba8ae4
4 changed files with 220 additions and 0 deletions

View File

@@ -169,6 +169,26 @@ describe('DataGrid layout', () => {
expect(markup).toContain('粘贴行');
});
it('renders a clickable copy action for aggregate query results', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
'COUNT(*)': 12,
},
]}
columnNames={['COUNT(*)']}
loading={false}
exportScope="queryResult"
/>,
);
expect(markup).toContain('data-grid-query-copy-action="true"');
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
expect(markup).toContain('复制');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid

View File

@@ -51,6 +51,12 @@ import {
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
import {
buildClipboardCsv,
buildClipboardJson,
buildClipboardMarkdown,
pickRowsForClipboard,
} from './dataGridClipboardExport';
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
import {
TEMPORAL_FORMATS,
@@ -4040,6 +4046,53 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.success("Copied to clipboard");
}, []);
const getClipboardRows = useCallback(() => (
pickRowsForClipboard({
rows: mergedDisplayData as Array<Record<string, unknown>>,
selectedRowKeys,
columnNames: visibleColumnNames,
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
})
), [mergedDisplayData, selectedRowKeys, visibleColumnNames, rowKeyStr]);
const getClipboardColumnNames = useCallback((rows: Array<Record<string, unknown>>) => {
if (rows.length === 0) return [];
return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
}, [visibleColumnNames]);
const handleCopyQueryResultCsv = useCallback(() => {
const rows = getClipboardRows();
const columns = getClipboardColumnNames(rows);
const text = buildClipboardCsv(rows, columns);
if (!text) {
void message.info('当前结果集没有可复制内容');
return;
}
copyToClipboard(text);
}, [copyToClipboard, getClipboardColumnNames, getClipboardRows]);
const handleCopyQueryResultJson = useCallback(() => {
const rows = getClipboardRows();
const text = buildClipboardJson(rows);
if (!text) {
void message.info('当前结果集没有可复制内容');
return;
}
copyToClipboard(text);
}, [copyToClipboard, getClipboardRows]);
const handleCopyQueryResultMarkdown = useCallback(() => {
const rows = getClipboardRows();
const columns = getClipboardColumnNames(rows);
const text = buildClipboardMarkdown(rows, columns);
if (!text) {
void message.info('当前结果集没有可复制内容');
return;
}
copyToClipboard(text);
}, [copyToClipboard, getClipboardColumnNames, getClipboardRows]);
const handleOpenTableDdl = useCallback(async () => {
if (!canViewDdl || !currentConnConfig || !tableName) {
void message.error('当前表缺少连接或表名,无法查看 DDL');
@@ -4597,6 +4650,13 @@ const DataGrid: React.FC<DataGridProps> = ({
{ key: 'html', label: 'HTML', onClick: () => handleExport('html') },
];
const queryResultCopyMenu: MenuProps['items'] = [
{ key: 'csv', label: 'CSV', onClick: handleCopyQueryResultCsv },
{ key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson },
{ key: 'markdown', label: 'Markdown', onClick: handleCopyQueryResultMarkdown },
];
const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && visibleColumnNames.length > 0;
const columnInfoSettingContent = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}></div>
@@ -5434,6 +5494,23 @@ const DataGrid: React.FC<DataGridProps> = ({
</>
)}
{isQueryResultExport && (
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Button
data-grid-query-copy-action="true"
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={handleCopyQueryResultCsv}
>
</Button>
<Dropdown menu={{ items: queryResultCopyMenu }} disabled={!canCopyQueryResult}>
<Button icon={<DownOutlined />} disabled={!canCopyQueryResult} />
</Dropdown>
</>
)}
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Tooltip title="一键借助 AI 智能分析当前查询页数据">

View File

@@ -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 }]);
});
});

View File

@@ -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<Record<string, unknown>>;
selectedRowKeys?: unknown[];
columnNames: string[];
rowKeyField: string;
rowKeyToString?: RowKeyToString;
}): Array<Record<string, unknown>> => {
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<string, unknown> = {};
columnNames.forEach((columnName) => {
if (!columnName || columnName === rowKeyField) return;
next[columnName] = row?.[columnName];
});
return next;
});
};
export const buildClipboardCsv = (rows: Array<Record<string, unknown>>, 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<Record<string, unknown>>, 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<Record<string, unknown>>): string => {
if (!Array.isArray(rows) || rows.length === 0) return '';
return JSON.stringify(rows, null, 2);
};