From 3ca898a95032a2b78ac2ab9bdacb2b31673a168b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 14:18:44 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-export):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8D=A1=E4=BD=8F=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=8C=89=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E8=83=BD=E5=8A=9B=E6=8E=A7=E5=88=B6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈 - DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级 - QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致 - 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性 --- .github/workflows/release.yml | 12 +- cmd/optional-driver-agent/main.go | 45 +++++- cmd/optional-driver-agent/main_test.go | 110 +++++++++++++ docs/driver-manifest.json | 2 +- frontend/src/components/DataGrid.tsx | 146 ++++++++++++------ frontend/src/components/DataViewer.tsx | 8 +- frontend/src/components/QueryEditor.tsx | 33 +++- frontend/src/utils/dataSourceCapabilities.ts | 86 +++++++++++ internal/app/methods_driver.go | 4 +- internal/app/methods_file.go | 67 +++++++- internal/app/methods_file_export_test.go | 116 ++++++++++++++ internal/db/clickhouse_impl.go | 11 +- internal/db/dsn_test.go | 26 +++- internal/db/optional_driver_agent_impl.go | 45 +++++- .../db/optional_driver_agent_impl_test.go | 32 ++++ 15 files changed, 672 insertions(+), 71 deletions(-) create mode 100644 frontend/src/utils/dataSourceCapabilities.ts create mode 100644 internal/db/optional_driver_agent_impl_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e0cb32..b373353 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -379,7 +379,7 @@ jobs: - name: List Assets run: ls -R release-assets - - name: Verify DuckDB Driver Assets + - name: Verify Optional Driver Assets shell: bash run: | set -euo pipefail @@ -390,20 +390,24 @@ jobs: "drivers/MacOS/duckdb-driver-agent-darwin-amd64" "drivers/MacOS/duckdb-driver-agent-darwin-arm64" "drivers/Linux/duckdb-driver-agent-linux-amd64" + "drivers/Windows/clickhouse-driver-agent-windows-amd64.exe" + "drivers/MacOS/clickhouse-driver-agent-darwin-amd64" + "drivers/MacOS/clickhouse-driver-agent-darwin-arm64" + "drivers/Linux/clickhouse-driver-agent-linux-amd64" ) missing=0 for file in "${REQUIRED_FILES[@]}"; do if [ ! -f "$file" ]; then - echo "❌ 缺少 DuckDB 驱动资产:$file" + echo "❌ 缺少驱动资产:$file" missing=1 else - echo "✅ 已找到 DuckDB 驱动资产:$file" + echo "✅ 已找到驱动资产:$file" fi done if [ "$missing" -ne 0 ]; then - echo "❌ DuckDB 驱动资产不完整,终止发布" + echo "❌ 可选驱动资产不完整,终止发布" exit 1 fi diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index 63f6945..4c0c5b9 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -2,11 +2,13 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "os" "reflect" "strings" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" @@ -17,6 +19,7 @@ type agentRequest struct { Method string `json:"method"` Config *connection.ConnectionConfig `json:"config,omitempty"` Query string `json:"query,omitempty"` + TimeoutMs int64 `json:"timeoutMs,omitempty"` DBName string `json:"dbName,omitempty"` TableName string `json:"tableName,omitempty"` Changes *connection.ChangeSet `json:"changes,omitempty"` @@ -48,6 +51,8 @@ const ( agentMethodApplyChanges = "applyChanges" ) +const legacyClickHouseDefaultTimeout = 2 * time.Hour + var ( agentDriverType string agentDatabaseFactory func() db.Database @@ -138,14 +143,14 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { return fail(resp, err.Error()) } case agentMethodQuery: - data, fields, err := (*inst).Query(req.Query) + data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } resp.Data = data resp.Fields = fields case agentMethodExec: - affected, err := (*inst).Exec(req.Query) + affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } @@ -287,3 +292,39 @@ func normalizeAgentResponseData(v interface{}) interface{} { return v } } + +func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { + effectiveTimeoutMs := timeoutMs + if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { + effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) + } + if effectiveTimeoutMs <= 0 { + return inst.Query(query) + } + if q, ok := inst.(interface { + QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) + }); ok { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) + defer cancel() + return q.QueryContext(ctx, query) + } + return inst.Query(query) +} + +func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) { + effectiveTimeoutMs := timeoutMs + if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { + effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) + } + if effectiveTimeoutMs <= 0 { + return inst.Exec(query) + } + if e, ok := inst.(interface { + ExecContext(context.Context, string) (int64, error) + }); ok { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) + defer cancel() + return e.ExecContext(ctx, query) + } + return inst.Exec(query) +} diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go index e74c805..016e520 100644 --- a/cmd/optional-driver-agent/main_test.go +++ b/cmd/optional-driver-agent/main_test.go @@ -3,8 +3,13 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" + "errors" "testing" + "time" + + "GoNavi-Wails/internal/connection" ) type duckMapLike map[any]any @@ -60,3 +65,108 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) { t.Fatalf("[]byte 内容被意外改写: %v", out) } } + +type fakeAgentTimeoutDB struct { + queryCalled bool + queryContextCalled bool + execCalled bool + execContextCalled bool + deadlineSet bool +} + +func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil } +func (f *fakeAgentTimeoutDB) Close() error { return nil } +func (f *fakeAgentTimeoutDB) Ping() error { return nil } +func (f *fakeAgentTimeoutDB) Query(query string) ([]map[string]interface{}, []string, error) { + f.queryCalled = true + return nil, nil, errors.New("query should not be called") +} +func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + f.queryContextCalled = true + if _, ok := ctx.Deadline(); ok { + f.deadlineSet = true + } + return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil +} +func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) { + f.execCalled = true + return 0, errors.New("exec should not be called") +} +func (f *fakeAgentTimeoutDB) ExecContext(ctx context.Context, query string) (int64, error) { + f.execContextCalled = true + if _, ok := ctx.Deadline(); ok { + f.deadlineSet = true + } + return 3, nil +} +func (f *fakeAgentTimeoutDB) GetDatabases() ([]string, error) { return nil, nil } +func (f *fakeAgentTimeoutDB) GetTables(dbName string) ([]string, error) { + return nil, nil +} +func (f *fakeAgentTimeoutDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (f *fakeAgentTimeoutDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (f *fakeAgentTimeoutDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (f *fakeAgentTimeoutDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) { + fake := &fakeAgentTimeoutDB{} + data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds())) + if err != nil { + t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err) + } + if !fake.queryContextCalled || fake.queryCalled { + t.Fatalf("query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled) + } + if !fake.deadlineSet { + t.Fatal("queryWithOptionalTimeout 未设置 deadline") + } + if len(data) != 1 || len(fields) != 1 || fields[0] != "ok" { + t.Fatalf("queryWithOptionalTimeout 返回数据异常: data=%v fields=%v", data, fields) + } +} + +func TestExecWithOptionalTimeout_UsesExecContext(t *testing.T) { + fake := &fakeAgentTimeoutDB{} + affected, err := execWithOptionalTimeout(fake, "DELETE FROM t", int64((2 * time.Second).Milliseconds())) + if err != nil { + t.Fatalf("execWithOptionalTimeout 返回错误: %v", err) + } + if !fake.execContextCalled || fake.execCalled { + t.Fatalf("exec 调用路径异常,ExecContext=%v Exec=%v", fake.execContextCalled, fake.execCalled) + } + if !fake.deadlineSet { + t.Fatal("execWithOptionalTimeout 未设置 deadline") + } + if affected != 3 { + t.Fatalf("受影响行数异常,want=3 got=%d", affected) + } +} + +func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testing.T) { + old := agentDriverType + agentDriverType = "clickhouse" + defer func() { agentDriverType = old }() + + fake := &fakeAgentTimeoutDB{} + _, _, err := queryWithOptionalTimeout(fake, "SELECT 1", 0) + if err != nil { + t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err) + } + if !fake.queryContextCalled || fake.queryCalled { + t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled) + } +} diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index 2352ea1..d04fba3 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -75,7 +75,7 @@ }, "clickhouse": { "engine": "go", - "version": "2.43.0", + "version": "2.43.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }, diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 797a1ab..51f6d2b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 {children}; - const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context; + const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu, supportsCopyInsert } = context; if (!enableRowContextMenu) { return {children}; @@ -460,12 +462,12 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { }; const menuItems: MenuProps['items'] = [ - { - key: 'insert', - label: `复制为 INSERT`, - icon: , - onClick: () => handleCopyInsert(record) - }, + ...(supportsCopyInsert ? [{ + key: 'insert', + label: '复制为 INSERT', + icon: , + onClick: () => handleCopyInsert(record), + }] : []), { key: 'json', label: '复制为 JSON', icon: , onClick: () => handleCopyJson(record) }, { key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) }, { key: 'copy', label: '复制为 Markdown', icon: , 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 = ({ - 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 = ({ 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 = ({ // 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 = ({ }, []); 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 = ({ 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 = ({ 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 = ({ // 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 = ({ 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 = ({ 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 = ({ 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 = ({ setSelectedRowKeys([]); onReload(); }}>刷新} - {tableName && } - {tableName && } + {canImport && } + {canExport && } {canModifyData && ( <> @@ -2996,21 +3050,23 @@ const DataGrid: React.FC = ({ 填充到选中行 ({selectedRowKeys.length})
-
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 -
+ {supportsCopyInsert && ( +
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 +
+ )}
= ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); const duckdbSafeSelectCacheRef = useRef>({}); - 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} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 347f43e..2d6a36e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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([]); // 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 />