🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性
This commit is contained in:
Syngnat
2026-03-02 14:18:44 +08:00
parent 84688e995a
commit 3ca898a950
15 changed files with 672 additions and 71 deletions

View File

@@ -12,6 +12,7 @@ import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -302,6 +303,7 @@ const DataContext = React.createContext<{
copyToClipboard: (t: string) => void;
tableName?: string;
enableRowContextMenu: boolean;
supportsCopyInsert: boolean;
} | null>(null);
interface Item {
@@ -444,7 +446,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
if (!record || !context) return <tr {...props}>{children}</tr>;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu, supportsCopyInsert } = context;
if (!enableRowContextMenu) {
return <tr {...props}>{children}</tr>;
@@ -460,12 +462,12 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
};
const menuItems: MenuProps['items'] = [
{
key: 'insert',
label: `复制为 INSERT`,
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyInsert(record)
},
...(supportsCopyInsert ? [{
key: 'insert',
label: '复制为 INSERT',
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyInsert(record),
}] : []),
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
@@ -502,6 +504,8 @@ interface DataGridProps {
columnNames: string[];
loading: boolean;
tableName?: string;
exportScope?: 'table' | 'queryResult';
resultSql?: string;
dbName?: string;
connectionId?: string;
pkColumns?: string[];
@@ -543,7 +547,7 @@ type ColumnMeta = {
};
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
@@ -559,8 +563,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const showColumnComment = queryOptions?.showColumnComment !== false;
const showColumnType = queryOptions?.showColumnType !== false;
const selectionColumnWidth = 46;
const connTypeLower = String(connections.find(c => c.id === connectionId)?.config?.type || '').trim().toLowerCase();
const isDuckDBConnection = connTypeLower === 'duckdb';
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
const isQueryResultExport = exportScope === 'queryResult';
const canImport = exportScope === 'table' && !!tableName;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
// Background Helper
const getBg = (darkHex: string) => {
@@ -687,11 +697,20 @@ const DataGrid: React.FC<DataGridProps> = ({
// Helper to export specific data
const exportData = async (rows: any[], format: string) => {
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest);
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, columnNames, tableName || 'export', format);
hide();
if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); }
try {
const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest);
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, columnNames, tableName || 'export', format);
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
}
} catch (e: any) {
message.error("导出失败: " + (e?.message || String(e)));
} finally {
hide();
}
};
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
@@ -2101,6 +2120,10 @@ const DataGrid: React.FC<DataGridProps> = ({
}, []);
const handleCopyInsert = useCallback((record: any) => {
if (!supportsCopyInsert) {
message.warning("当前数据源不支持复制为 INSERT请使用 JSON/CSV/Markdown 复制。");
return;
}
const records = getTargets(record);
const sqls = records.map((r: any) => {
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r;
@@ -2110,7 +2133,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
});
copyToClipboard(sqls.join('\n'));
}, [tableName, getTargets, copyToClipboard]);
}, [supportsCopyInsert, tableName, getTargets, copyToClipboard]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
@@ -2149,12 +2172,17 @@ const DataGrid: React.FC<DataGridProps> = ({
const config = buildConnConfig();
if (!config) return;
const hide = message.loading(`正在导出...`, 0);
const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format);
hide();
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
try {
const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format);
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
}
} catch (e: any) {
message.error("导出失败: " + (e?.message || String(e)));
} finally {
hide();
}
}, [buildConnConfig, dbName]);
@@ -2198,6 +2226,10 @@ const DataGrid: React.FC<DataGridProps> = ({
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
const records = getTargets(record);
if (isQueryResultExport) {
await exportData(records, format);
return;
}
if (!connectionId || !tableName) {
await exportData(records, format);
return;
@@ -2225,11 +2257,11 @@ const DataGrid: React.FC<DataGridProps> = ({
const sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} WHERE ${pkWhere}`;
await exportByQuery(sql, format, tableName || 'export');
}, [getTargets, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]);
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]);
// Export
const handleExport = async (format: string) => {
if (!connectionId || !tableName) return;
if (!connectionId) return;
// 1. Export Selected
if (selectedRowKeys.length > 0) {
@@ -2238,17 +2270,38 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
// 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。
if (isQueryResultExport) {
const sql = String(resultSql || '').trim();
if (!hasChanges && supportsSqlQueryExport && sql) {
await exportByQuery(sql, format, tableName || 'query_result');
} else {
await exportData(mergedDisplayData, format);
}
return;
}
// 2. Prompt for Current vs All
// Using a custom modal content with buttons to handle 3 states
let instance: any;
const handleAll = async () => {
instance.destroy();
if (!tableName) return;
const config = buildConnConfig();
if (!config) return;
const hide = message.loading(`正在导出全部数据...`, 0);
const res = await ExportTable(config as any, dbName || '', tableName, format);
hide();
if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); }
try {
const res = await ExportTable(config as any, dbName || '', tableName, format);
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
}
} catch (e: any) {
message.error("导出失败: " + (e?.message || String(e)));
} finally {
hide();
}
};
const handlePage = async () => {
instance.destroy();
@@ -2411,7 +2464,8 @@ const DataGrid: React.FC<DataGridProps> = ({
copyToClipboard,
tableName,
enableRowContextMenu: !canModifyData,
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData]);
supportsCopyInsert,
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData, supportsCopyInsert]);
const cellContextMenuValue = useMemo(() => ({
showMenu: showCellContextMenu,
@@ -2456,8 +2510,8 @@ const DataGrid: React.FC<DataGridProps> = ({
setSelectedRowKeys([]);
onReload();
}}></Button>}
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}></Button>}
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
{canImport && <Button icon={<ImportOutlined />} onClick={handleImport}></Button>}
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
{canModifyData && (
<>
@@ -2996,21 +3050,23 @@ const DataGrid: React.FC<DataGridProps> = ({
({selectedRowKeys.length})
</div>
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
INSERT
</div>
{supportsCopyInsert && (
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
INSERT
</div>
)}
<div
style={{
padding: '8px 12px',

View File

@@ -5,6 +5,7 @@ import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
type ViewerPaginationState = {
current: number;
@@ -172,8 +173,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse';
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
useEffect(() => {
setPkColumns([]);
@@ -673,6 +676,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
exportScope="table"
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
@@ -7,6 +7,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
@@ -14,6 +15,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
type ResultSet = {
key: string;
sql: string;
exportSql?: string;
rows: any[];
columns: string[];
tableName?: string;
@@ -47,6 +49,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
const connections = useStore(state => state.connections);
const queryCapableConnections = useMemo(
() => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor),
[connections]
);
const addSqlLog = useStore(state => state.addSqlLog);
const currentConnectionIdRef = useRef(currentConnectionId);
const currentDbRef = useRef(currentDb);
@@ -64,6 +70,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
currentConnectionIdRef.current = currentConnectionId;
}, [currentConnectionId]);
useEffect(() => {
if (!queryCapableConnections.some(c => c.id === currentConnectionId)) {
const fallback = queryCapableConnections[0]?.id || '';
if (fallback && fallback !== currentConnectionId) {
setCurrentConnectionId(fallback);
setCurrentDb('');
}
}
}, [queryCapableConnections, currentConnectionId]);
useEffect(() => {
currentDbRef.current = currentDb;
}, [currentDb]);
@@ -977,6 +993,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
if (runSeqRef.current === runSeq) setLoading(false);
return;
}
const connCaps = getDataSourceCapabilities(conn.config);
if (!connCaps.supportsQueryEditor) {
message.error("当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。");
if (runSeqRef.current === runSeq) setLoading(false);
return;
}
const config = {
...conn.config,
@@ -1000,8 +1022,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const dbType = String((config as any).type || 'mysql');
const normalizedDbType = dbType.toLowerCase();
const forceReadOnlyResult = normalizedDbType === 'tdengine' || normalizedDbType === 'clickhouse';
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
let anyTruncated = false;
@@ -1066,6 +1087,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement,
rows,
columns: cols,
tableName: simpleTableName,
@@ -1082,6 +1104,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
@@ -1223,7 +1246,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setCurrentConnectionId(val);
setCurrentDb('');
}}
options={connections.map(c => ({ label: c.name, value: c.id }))}
options={queryCapableConnections.map(c => ({ label: c.name, value: c.id }))}
showSearch
/>
<Select
@@ -1333,6 +1356,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}

View File

@@ -0,0 +1,86 @@
import type { ConnectionConfig } from '../types';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
switch (normalized) {
case 'doris':
return 'diros';
case 'postgresql':
return 'postgres';
case 'dm':
return 'dameng';
default:
return normalized;
}
};
export const resolveDataSourceType = (config: ConnectionLike): string => {
if (!config) return '';
const type = normalizeDataSourceToken(String(config.type || ''));
if (type === 'custom') {
const driver = normalizeDataSourceToken(String(config.driver || ''));
return driver || 'custom';
}
return type;
};
const SQL_QUERY_EXPORT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const COPY_INSERT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
export type DataSourceCapabilities = {
type: string;
supportsQueryEditor: boolean;
supportsSqlQueryExport: boolean;
supportsCopyInsert: boolean;
forceReadOnlyQueryResult: boolean;
};
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
const type = resolveDataSourceType(config);
return {
type,
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
};
};