mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-08 07:29:44 +08:00
🐛 fix(DataGrid): 修复聚合查询结果无法复制的问题
- 为查询结果页新增独立复制入口 - 支持 CSV、JSON、Markdown 复制当前结果集 - 补充聚合列复制与按钮可点击回归测试
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 智能分析当前查询页数据">
|
||||
|
||||
40
frontend/src/components/dataGridClipboardExport.test.ts
Normal file
40
frontend/src/components/dataGridClipboardExport.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
83
frontend/src/components/dataGridClipboardExport.ts
Normal file
83
frontend/src/components/dataGridClipboardExport.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user