From b53227cb1592b27161ad20147eb580484169e1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=A3=E6=9D=A1?= <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:55:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=20=20-=20feat(connection,metadata,kingbase?= =?UTF-8?q?):=20=E5=A2=9E=E5=BC=BA=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E8=83=BD=E5=8A=9B=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=87=91=E4=BB=93/=E8=BE=BE=E6=A2=A6/Oracle/ClickHouse?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E9=97=AE=E9=A2=98=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源 refs #168 * fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销 refs #178 * fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误 refs #176 * fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败 refs #177 * chore(ci): 新增手动触发的 macOS 测试构建工作流 * chore(ci): 允许测试工作流在当前分支自动触发 * fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 * feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 * fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 * fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 * fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 * fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 --- .github/workflows/test-macos-build.yml | 91 ++++++++ frontend/package.json.md5 | 2 +- frontend/src/components/ConnectionModal.tsx | 136 ++++++++++-- frontend/src/components/DataGrid.tsx | 31 ++- frontend/src/components/DataSyncModal.tsx | 231 +++++++++++++++++--- frontend/src/components/QueryEditor.tsx | 38 +++- frontend/src/components/Sidebar.tsx | 40 +++- frontend/src/store.ts | 16 +- frontend/src/types.ts | 9 + frontend/wailsjs/go/models.ts | 23 ++ internal/app/app.go | 11 + internal/app/db_proxy.go | 29 +++ internal/app/global_proxy.go | 2 +- internal/app/methods_db.go | 21 +- internal/app/methods_driver.go | 36 ++- internal/app/methods_redis.go | 47 +++- internal/connection/types.go | 72 +++--- internal/db/clickhouse_impl.go | 84 ++++++- internal/db/dameng_impl.go | 81 ++++++- internal/db/driver_agent_binary_check.go | 74 +++++++ internal/db/driver_support.go | 3 + internal/db/driver_support_test.go | 19 +- internal/db/kingbase_impl.go | 91 ++++++-- internal/db/kingbase_impl_test.go | 74 +++++++ internal/db/optional_driver_agent_impl.go | 29 +++ internal/db/query_value.go | 11 + internal/db/query_value_test.go | 30 +++ 27 files changed, 1162 insertions(+), 169 deletions(-) create mode 100644 .github/workflows/test-macos-build.yml create mode 100644 internal/db/driver_agent_binary_check.go create mode 100644 internal/db/kingbase_impl_test.go diff --git a/.github/workflows/test-macos-build.yml b/.github/workflows/test-macos-build.yml new file mode 100644 index 0000000..1dd01af --- /dev/null +++ b/.github/workflows/test-macos-build.yml @@ -0,0 +1,91 @@ +name: Test Build macOS (Manual) + +on: + workflow_dispatch: + inputs: + build_label: + description: "测试包标识(仅用于文件名)" + required: false + default: "test" + push: + branches: + - feature/kingbase_opt + paths: + - ".github/workflows/test-macos-build.yml" + +permissions: + contents: read + +jobs: + build-macos: + name: Build macOS ${{ matrix.arch }} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - platform: darwin/amd64 + arch: amd64 + - platform: darwin/arm64 + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.3" + check-latest: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Wails + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + + - name: Build App + run: | + set -euo pipefail + OUTPUT_NAME="gonavi-test-${{ matrix.arch }}" + BUILD_LABEL="${{ inputs.build_label }}" + if [ -z "$BUILD_LABEL" ]; then + BUILD_LABEL="test" + fi + APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" + wails build \ + -platform "${{ matrix.platform }}" \ + -clean \ + -o "$OUTPUT_NAME" \ + -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" + + - name: Package Zip + run: | + set -euo pipefail + APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app" + if [ ! -d "$APP_PATH" ]; then + APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true) + fi + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "未找到 .app 产物" + ls -la build/bin || true + exit 1 + fi + LABEL="${{ inputs.build_label }}" + if [ -z "$LABEL" ]; then + LABEL="test" + fi + ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip" + mkdir -p artifacts + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME" + shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256" + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }} + path: artifacts/* + if-no-files-found: error diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0f8f4fe..a7661c0 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file +d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 45ef1a8..85aa4c6 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -101,6 +101,7 @@ const ConnectionModal: React.FC<{ const [useSSL, setUseSSL] = useState(false); const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); + const [useHttpTunnel, setUseHttpTunnel] = useState(false); const [dbType, setDbType] = useState('mysql'); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 @@ -1026,6 +1027,8 @@ const ConnectionModal: React.FC<{ const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet; const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0; + const hasHttpTunnel = !!config.useHttpTunnel; + const hasProxy = !hasHttpTunnel && !!config.useProxy; form.setFieldsValue({ type: configType, name: initialValues.name, @@ -1047,12 +1050,17 @@ const ConnectionModal: React.FC<{ sshUser: config.ssh?.user, sshPassword: config.ssh?.password, sshKeyPath: config.ssh?.keyPath, - useProxy: config.useProxy, + useProxy: hasProxy, proxyType: config.proxy?.type || 'socks5', proxyHost: config.proxy?.host, proxyPort: config.proxy?.port, proxyUser: config.proxy?.user, proxyPassword: config.proxy?.password, + useHttpTunnel: hasHttpTunnel, + httpTunnelHost: config.httpTunnel?.host, + httpTunnelPort: config.httpTunnel?.port || 8080, + httpTunnelUser: config.httpTunnel?.user, + httpTunnelPassword: config.httpTunnel?.password, driver: config.driver, dsn: config.dsn, timeout: config.timeout || 30, @@ -1076,7 +1084,8 @@ const ConnectionModal: React.FC<{ }); setUseSSL(!!config.useSSL); setUseSSH(config.useSSH || false); - setUseProxy(config.useProxy || false); + setUseProxy(hasProxy); + setUseHttpTunnel(hasHttpTunnel); setDbType(configType); // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 if (configType === 'redis') { @@ -1089,6 +1098,7 @@ const ConnectionModal: React.FC<{ setUseSSL(false); setUseSSH(false); setUseProxy(false); + setUseHttpTunnel(false); setDbType('mysql'); setActiveGroup(0); } @@ -1140,6 +1150,7 @@ const ConnectionModal: React.FC<{ setUseSSL(false); setUseSSH(false); setUseProxy(false); + setUseHttpTunnel(false); setDbType('mysql'); setStep(1); onClose(); @@ -1185,19 +1196,24 @@ const ConnectionModal: React.FC<{ ? await RedisConnect(config as any) : await TestConnection(config as any); - if (res.success) { - setTestResult({ type: 'success', message: res.message }); - if (isRedisType) { - setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); - } else { - // Other databases: fetch database list - const dbRes = await DBGetDatabases(config as any); - if (dbRes.success) { - const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database); - setDbList(dbs); - } - } - } else { + if (res.success) { + setTestResult({ type: 'success', message: res.message }); + if (isRedisType) { + setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); + } else { + // Other databases: fetch database list + const dbRes = await DBGetDatabases(config as any); + if (dbRes.success) { + const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; + const dbs = dbRows + .map((row: any) => row?.Database || row?.database) + .filter((name: any) => typeof name === 'string' && name.trim() !== ''); + setDbList(dbs); + } else { + setDbList([]); + } + } + } else { const failMessage = buildTestFailureMessage( res?.message, '连接被拒绝或参数无效,请检查后重试' @@ -1388,7 +1404,8 @@ const ConnectionModal: React.FC<{ password: mergedValues.sshPassword || "", keyPath: mergedValues.sshKeyPath || "" } : { host: "", port: 22, user: "", password: "", keyPath: "" }; - const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy; + const effectiveUseHttpTunnel = !isFileDbType && !!mergedValues.useHttpTunnel; + const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase(); const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5'; const proxyConfig: NonNullable = effectiveUseProxy ? { @@ -1404,6 +1421,25 @@ const ConnectionModal: React.FC<{ user: '', password: '', }; + const httpTunnelConfig: NonNullable = effectiveUseHttpTunnel ? { + host: String(mergedValues.httpTunnelHost || '').trim(), + port: Number(mergedValues.httpTunnelPort || 8080), + user: String(mergedValues.httpTunnelUser || '').trim(), + password: mergedValues.httpTunnelPassword || "", + } : { + host: '', + port: 8080, + user: '', + password: '', + }; + if (effectiveUseHttpTunnel) { + if (!httpTunnelConfig.host) { + throw new Error('HTTP 隧道主机不能为空'); + } + if (!Number.isFinite(httpTunnelConfig.port) || httpTunnelConfig.port <= 0 || httpTunnelConfig.port > 65535) { + throw new Error('HTTP 隧道端口必须在 1-65535 之间'); + } + } const keepPassword = !forPersist || savePassword; @@ -1423,6 +1459,8 @@ const ConnectionModal: React.FC<{ ssh: sshConfig, useProxy: effectiveUseProxy, proxy: proxyConfig, + useHttpTunnel: effectiveUseHttpTunnel, + httpTunnel: httpTunnelConfig, driver: mergedValues.driver, dsn: mergedValues.dsn, timeout: Number(mergedValues.timeout || 30), @@ -1461,6 +1499,7 @@ const ConnectionModal: React.FC<{ setUseSSL(false); setUseSSH(false); setUseProxy(false); + setUseHttpTunnel(false); form.setFieldsValue({ host: '', port: 0, @@ -1483,6 +1522,11 @@ const ConnectionModal: React.FC<{ proxyPort: 1080, proxyUser: '', proxyPassword: '', + useHttpTunnel: false, + httpTunnelHost: '', + httpTunnelPort: 8080, + httpTunnelUser: '', + httpTunnelPassword: '', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', @@ -1505,6 +1549,7 @@ const ConnectionModal: React.FC<{ const defaultUser = type === 'clickhouse' ? 'default' : 'root'; const sslCapableType = supportsSSLForType(type); setUseSSL(false); + setUseHttpTunnel(false); form.setFieldsValue({ user: defaultUser, database: '', @@ -1513,6 +1558,11 @@ const ConnectionModal: React.FC<{ sslMode: sslCapableType ? 'preferred' : undefined, sslCertPath: sslCapableType ? '' : undefined, sslKeyPath: sslCapableType ? '' : undefined, + useHttpTunnel: false, + httpTunnelHost: '', + httpTunnelPort: 8080, + httpTunnelUser: '', + httpTunnelPassword: '', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', @@ -1665,6 +1715,8 @@ const ConnectionModal: React.FC<{ useProxy: false, proxyType: 'socks5', proxyPort: 1080, + useHttpTunnel: false, + httpTunnelPort: 8080, timeout: 30, uri: '', mysqlTopology: 'single', @@ -1693,7 +1745,14 @@ const ConnectionModal: React.FC<{ } if (changed.useSSL !== undefined) setUseSSL(changed.useSSL); if (changed.useSSH !== undefined) setUseSSH(changed.useSSH); - if (changed.useProxy !== undefined) setUseProxy(changed.useProxy); + if (changed.useProxy !== undefined) { + const enabledProxy = !!changed.useProxy; + setUseProxy(enabledProxy); + if (enabledProxy && form.getFieldValue('useHttpTunnel')) { + form.setFieldValue('useHttpTunnel', false); + setUseHttpTunnel(false); + } + } if (changed.proxyType !== undefined) { const nextType = String(changed.proxyType || 'socks5').toLowerCase(); if (nextType === 'http') { @@ -1708,6 +1767,20 @@ const ConnectionModal: React.FC<{ } } } + if (changed.useHttpTunnel !== undefined) { + const enabledHttpTunnel = !!changed.useHttpTunnel; + setUseHttpTunnel(enabledHttpTunnel); + if (enabledHttpTunnel && form.getFieldValue('useProxy')) { + form.setFieldValue('useProxy', false); + setUseProxy(false); + } + if (enabledHttpTunnel) { + const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0); + if (!currentPort || currentPort <= 0) { + form.setFieldValue('httpTunnelPort', 8080); + } + } + } // Type change handled by step 1, but keep sync if select changes (hidden now) if (changed.type !== undefined) setDbType(changed.type); if (changed.redisTopology !== undefined) { @@ -2194,6 +2267,35 @@ const ConnectionModal: React.FC<{ )} + + + 使用 HTTP 隧道(独立代理) + + + {useHttpTunnel && ( +
+
+ + + + + + +
+
+ + + + + + +
+ + 与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。 + +
+ )} + { try { if (val === null) return NULL; if (typeof val === 'object') { + if (!Array.isArray(val) && !isPlainObject(val)) { + return String(val); + } const cached = objectCellPreviewCache.get(val); if (cached !== undefined) { return cached; } + const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length; + if (topLevelSize > 80) { + const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`; + objectCellPreviewCache.set(val, summary); + return summary; + } try { const nextText = JSON.stringify(val); const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; @@ -191,6 +200,26 @@ const isCellValueEqualForDiff = (left: any, right: any): boolean => { return toFormText(left) === toFormText(right); }; +// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。 +const isCellValueEqualForRender = (left: any, right: any): boolean => { + if (left === right) return true; + const leftNullish = left === null || left === undefined; + const rightNullish = right === null || right === undefined; + if (leftNullish || rightNullish) return leftNullish && rightNullish; + + const leftType = typeof left; + const rightType = typeof right; + if (leftType === 'object' || rightType === 'object') { + // 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。 + return false; + } + + if (leftType === 'string' || rightType === 'string') { + return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right)); + } + return left === right; +}; + const INLINE_EDIT_MAX_CHARS = 2000; const shouldOpenModalEditor = (val: any): boolean => { @@ -2067,7 +2096,7 @@ const DataGrid: React.FC = ({ shouldCellUpdate: (record: Item, prevRecord: Item) => { const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY]; if (rowKeyChanged) return true; - return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]); + return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]); }, onHeaderCell: (column: any) => ({ width: column.width, diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 769885a..57c4033 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd'; import { useStore } from '../store'; import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App'; @@ -31,6 +31,118 @@ type TableOps = { selectedDeletePks?: string[]; }; +const quoteSqlIdent = (dbType: string, ident: string): string => { + const raw = String(ident || '').trim(); + if (!raw) return raw; + const t = String(dbType || '').toLowerCase(); + if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') { + return `\`${raw.replace(/`/g, '``')}\``; + } + if (t === 'sqlserver') { + return `[${raw.replace(/]/g, ']]')}]`; + } + return `"${raw.replace(/"/g, '""')}"`; +}; + +const quoteSqlTable = (dbType: string, tableName: string): string => { + const raw = String(tableName || '').trim(); + if (!raw) return raw; + if (!raw.includes('.')) return quoteSqlIdent(dbType, raw); + return raw + .split('.') + .map((part) => quoteSqlIdent(dbType, part)) + .join('.'); +}; + +const toSqlLiteral = (value: any, dbType: string): string => { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL'; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'boolean') { + const t = String(dbType || '').toLowerCase(); + if (t === 'sqlserver') return value ? '1' : '0'; + return value ? 'TRUE' : 'FALSE'; + } + if (value instanceof Date) { + return `'${value.toISOString().replace(/'/g, "''")}'`; + } + if (typeof value === 'object') { + try { + return `'${JSON.stringify(value).replace(/'/g, "''")}'`; + } catch { + return `'${String(value).replace(/'/g, "''")}'`; + } + } + return `'${String(value).replace(/'/g, "''")}'`; +}; + +const buildSqlPreview = ( + previewData: any, + tableName: string, + dbType: string, + ops?: TableOps, +): { sqlText: string; statementCount: number } => { + if (!previewData || !tableName) return { sqlText: '', statementCount: 0 }; + const tableExpr = quoteSqlTable(dbType, tableName); + const pkCol = String(previewData.pkColumn || 'id'); + const statements: string[] = []; + + const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : []; + const updateRows = Array.isArray(previewData.updates) ? previewData.updates : []; + const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : []; + + const selectedInsert = new Set((ops?.selectedInsertPks || []).map((v) => String(v))); + const selectedUpdate = new Set((ops?.selectedUpdatePks || []).map((v) => String(v))); + const selectedDelete = new Set((ops?.selectedDeletePks || []).map((v) => String(v))); + + if (ops?.insert !== false) { + insertRows.forEach((rowWrap: any) => { + const pk = String(rowWrap?.pk ?? ''); + if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return; + const row = rowWrap?.row || {}; + const columns = Object.keys(row); + if (columns.length === 0) return; + const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', '); + const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', '); + statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`); + }); + } + + if (ops?.update !== false) { + updateRows.forEach((rowWrap: any) => { + const pk = String(rowWrap?.pk ?? ''); + if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return; + const source = rowWrap?.source || {}; + const changedColumns = Array.isArray(rowWrap?.changedColumns) + ? rowWrap.changedColumns + : Object.keys(source).filter((k) => k !== pkCol); + const setCols = changedColumns.filter((c: string) => String(c) !== pkCol); + if (setCols.length === 0) return; + const setExpr = setCols + .map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`) + .join(', '); + statements.push( + `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + ); + }); + } + + if (ops?.delete) { + deleteRows.forEach((rowWrap: any) => { + const pk = String(rowWrap?.pk ?? ''); + if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return; + statements.push( + `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + ); + }); + } + + return { + sqlText: statements.join('\n'), + statementCount: statements.length, + }; +}; + const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const connections = useStore((state) => state.connections); const [currentStep, setCurrentStep] = useState(0); @@ -152,32 +264,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, setSourceConnId(connId); setSourceDb(''); const conn = connections.find(c => c.id === connId); - if (conn) { - setLoading(true); - try { - const res = await DBGetDatabases(normalizeConnConfig(conn) as any); - if (res.success) { - setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username)); - } - } catch(e) { message.error("Failed to fetch source databases"); } - setLoading(false); - } + if (conn) { + setLoading(true); + try { + const res = await DBGetDatabases(normalizeConnConfig(conn) as any); + if (res.success) { + const dbRows = Array.isArray(res.data) ? res.data : []; + setSourceDbs(dbRows + .map((r: any) => r?.Database || r?.database || r?.username) + .filter((name: any) => typeof name === 'string' && name.trim() !== '')); + } + } catch(e) { message.error("Failed to fetch source databases"); } + setLoading(false); + } }; const handleTargetConnChange = async (connId: string) => { setTargetConnId(connId); setTargetDb(''); const conn = connections.find(c => c.id === connId); - if (conn) { - setLoading(true); - try { - const res = await DBGetDatabases(normalizeConnConfig(conn) as any); - if (res.success) { - setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username)); - } - } catch(e) { message.error("Failed to fetch target databases"); } - setLoading(false); - } + if (conn) { + setLoading(true); + try { + const res = await DBGetDatabases(normalizeConnConfig(conn) as any); + if (res.success) { + const dbRows = Array.isArray(res.data) ? res.data : []; + setTargetDbs(dbRows + .map((r: any) => r?.Database || r?.database || r?.username) + .filter((name: any) => typeof name === 'string' && name.trim() !== '')); + } + } catch(e) { message.error("Failed to fetch target databases"); } + setLoading(false); + } }; const nextToTables = async () => { @@ -189,14 +307,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, try { const conn = connections.find(c => c.id === sourceConnId); if (conn) { - const config = normalizeConnConfig(conn, sourceDb); - const res = await DBGetTables(config as any, sourceDb); - if (res.success) { - // DBGetTables returns [{Table: "name"}, ...] - const tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]); - setAllTables(tables as string[]); - setCurrentStep(1); - } else { + const config = normalizeConnConfig(conn, sourceDb); + const res = await DBGetTables(config as any, sourceDb); + if (res.success) { + // DBGetTables returns [{Table: "name"}, ...] + const tableRows = Array.isArray(res.data) ? res.data : []; + const tables = tableRows + .map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0]) + .filter((name: any) => typeof name === 'string' && name.trim() !== ''); + setAllTables(tables as string[]); + setCurrentStep(1); + } else { message.error(res.message); } } @@ -402,6 +523,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, ); }; + const previewSql = useMemo(() => { + if (!previewData || !previewTable) return { sqlText: '', statementCount: 0 }; + const targetType = String(connections.find(c => c.id === targetConnId)?.config?.type || ''); + const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false }; + return buildSqlPreview(previewData, previewTable, targetType, ops); + }, [previewData, previewTable, targetConnId, connections, tableOptions]); + return ( <> void }> = ({ open, /> ) + }, + { + key: 'sql', + label: `SQL(${previewSql.statementCount})`, + children: ( +
+ +
+ 共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型) + +
+
+                                        {previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
+                                    
+
+ ) } ]} /> diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 2e66344..69294d1 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -48,6 +48,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [editorHeight, setEditorHeight] = useState(300); const editorRef = useRef(null); const monacoRef = useRef(null); + const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db) const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db) @@ -95,10 +96,30 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { connectionsRef.current = connections; }, [connections]); + const getCurrentQuery = () => { + const val = editorRef.current?.getValue?.(); + if (typeof val === 'string') return val; + return query || ''; + }; + + const syncQueryToEditor = (sql: string) => { + const next = sql || ''; + setQuery(next); + const editor = editorRef.current; + if (editor && editor.getValue?.() !== next) { + editor.setValue(next); + } + }; + // If opening a saved query, load its SQL useEffect(() => { - if (tab.query) setQuery(tab.query); - }, [tab.query]); + const incoming = tab.query || ''; + if (incoming === lastExternalQueryRef.current) { + return; + } + lastExternalQueryRef.current = incoming; + syncQueryToEditor(incoming || 'SELECT * FROM '); + }, [tab.id, tab.query]); // Fetch Database List useEffect(() => { @@ -557,8 +578,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const handleFormat = () => { try { - const formatted = format(query, { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); - setQuery(formatted); + const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); + syncQueryToEditor(formatted); } catch (e) { message.error("格式化失败: SQL 语法可能有误"); } @@ -1045,7 +1066,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; const handleRun = async () => { - if (!query.trim()) return; + const currentQuery = getCurrentQuery(); + if (!currentQuery.trim()) return; if (!currentDb) { message.error("请先选择数据库"); return; @@ -1086,7 +1108,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; try { - const rawSQL = getSelectedSQL() || query; + const rawSQL = getSelectedSQL() || currentQuery; const dbType = String((config as any).type || 'mysql'); const normalizedDbType = dbType.trim().toLowerCase(); const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); @@ -1367,7 +1389,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { saveQuery({ id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`, name: values.name, - sql: query, + sql: getCurrentQuery(), connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', createdAt: Date.now() @@ -1512,7 +1534,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { height="100%" defaultLanguage="sql" theme={darkMode ? "transparent-dark" : "transparent-light"} - value={query} + defaultValue={query} onChange={(val) => setQuery(val || '')} onMount={handleEditorDidMount} options={{ diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6420955..2fa3fdd 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -382,6 +382,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword), }; const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password); + const rawHttpTunnel = (cloned.httpTunnel ?? cloned.HTTPTunnel ?? {}) as Record; + const normalizedHttpTunnel = { + host: readString(rawHttpTunnel.host, rawHttpTunnel.Host, cloned.httpTunnelHost, cloned.HttpTunnelHost), + port: readNumber(8080, rawHttpTunnel.port, rawHttpTunnel.Port, cloned.httpTunnelPort, cloned.HttpTunnelPort), + user: readString(rawHttpTunnel.user, rawHttpTunnel.User, cloned.httpTunnelUser, cloned.HttpTunnelUser), + password: readString(rawHttpTunnel.password, rawHttpTunnel.Password, cloned.httpTunnelPassword, cloned.HttpTunnelPassword), + }; + const hasHttpTunnelDetail = Boolean(normalizedHttpTunnel.host || normalizedHttpTunnel.user || normalizedHttpTunnel.password); + const normalizedUseHttpTunnel = readBool(hasHttpTunnelDetail, cloned.useHttpTunnel, cloned.UseHTTPTunnel); + const normalizedUseProxy = !normalizedUseHttpTunnel && readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy); const rawHosts = Array.isArray(cloned.hosts) ? cloned.hosts @@ -394,8 +404,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ...(cloned as SavedConnection['config']), useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH), ssh: normalizedSSH, - useProxy: readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy), + useProxy: normalizedUseProxy, proxy: normalizedProxy, + useHttpTunnel: normalizedUseHttpTunnel, + httpTunnel: normalizedHttpTunnel, hosts: normalizedHosts, timeout: readNumber(30, cloned.timeout, cloned.Timeout), }; @@ -645,10 +657,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } case 'oracle': case 'dm': - if (!safeDbName) { - return [{ sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }]; - } - return [{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` }]; + return normalizeMetadataQuerySpecs([ + { sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }, + { sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` + : '', + }, + ]); case 'sqlite': return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; case 'duckdb': @@ -731,10 +748,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } case 'oracle': case 'dm': - if (!safeDbName) { - return [{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }]; - } - return [{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }]; + return normalizeMetadataQuerySpecs([ + { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` + : '', + }, + ]); case 'duckdb': return [{ sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 42f3fb6..e3b44f5 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -231,6 +231,18 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { user: toTrimmedString(proxyRaw.user), password: toTrimmedString(proxyRaw.password), }; + const httpTunnelRaw = (raw.httpTunnel && typeof raw.httpTunnel === 'object') + ? raw.httpTunnel as Record + : ((raw.HTTPTunnel && typeof raw.HTTPTunnel === 'object') ? raw.HTTPTunnel as Record : {}); + const httpTunnel = { + host: toTrimmedString(httpTunnelRaw.host ?? raw.httpTunnelHost), + port: normalizePort(httpTunnelRaw.port ?? raw.httpTunnelPort, 8080), + user: toTrimmedString(httpTunnelRaw.user ?? raw.httpTunnelUser), + password: toTrimmedString(httpTunnelRaw.password ?? raw.httpTunnelPassword), + }; + const supportsNetworkTunnel = type !== 'sqlite' && type !== 'duckdb'; + const useHttpTunnel = supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true); + const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel; const safeConfig: ConnectionConfig & Record = { ...raw, @@ -247,8 +259,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : '', useSSH: !!raw.useSSH, ssh, - useProxy: !!raw.useProxy, + useProxy, proxy, + useHttpTunnel, + httpTunnel, uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH), hosts: sanitizeAddressList(raw.hosts), topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 501a854..96ac6da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -14,6 +14,13 @@ export interface ProxyConfig { password?: string; } +export interface HTTPTunnelConfig { + host: string; + port: number; + user?: string; + password?: string; +} + export interface ConnectionConfig { type: string; host: string; @@ -30,6 +37,8 @@ export interface ConnectionConfig { ssh?: SSHConfig; useProxy?: boolean; proxy?: ProxyConfig; + useHttpTunnel?: boolean; + httpTunnel?: HTTPTunnelConfig; driver?: string; dsn?: string; timeout?: number; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bca7b39..2de678a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -48,6 +48,24 @@ export namespace connection { return a; } } + export class HTTPTunnelConfig { + host: string; + port: number; + user?: string; + password?: string; + + static createFrom(source: any = {}) { + return new HTTPTunnelConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.password = source["password"]; + } + } export class ProxyConfig { type: string; host: string; @@ -104,6 +122,8 @@ export namespace connection { ssh: SSHConfig; useProxy?: boolean; proxy?: ProxyConfig; + useHttpTunnel?: boolean; + httpTunnel?: HTTPTunnelConfig; driver?: string; dsn?: string; timeout?: number; @@ -142,6 +162,8 @@ export namespace connection { this.ssh = this.convertValues(source["ssh"], SSHConfig); this.useProxy = source["useProxy"]; this.proxy = this.convertValues(source["proxy"], ProxyConfig); + this.useHttpTunnel = source["useHttpTunnel"]; + this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig); this.driver = source["driver"]; this.dsn = source["dsn"]; this.timeout = source["timeout"]; @@ -179,6 +201,7 @@ export namespace connection { } } + export class QueryResult { success: boolean; message: string; diff --git a/internal/app/app.go b/internal/app/app.go index 789f7be..0709a27 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,6 +96,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn if !normalized.UseProxy { normalized.Proxy = connection.ProxyConfig{} } + if !normalized.UseHTTPTunnel { + normalized.HTTPTunnel = connection.HTTPTunnelConfig{} + } if isFileDatabaseType(normalized.Type) { dsn := strings.TrimSpace(normalized.Host) @@ -124,6 +127,8 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn normalized.MongoAuthMechanism = "" normalized.MongoReplicaUser = "" normalized.MongoReplicaPassword = "" + normalized.UseHTTPTunnel = false + normalized.HTTPTunnel = connection.HTTPTunnelConfig{} } return normalized @@ -303,6 +308,12 @@ func formatConnSummary(config connection.ConnectionConfig) string { b.WriteString(" 代理认证=已配置") } } + if config.UseHTTPTunnel { + b.WriteString(fmt.Sprintf(" HTTP隧道=%s:%d", strings.TrimSpace(config.HTTPTunnel.Host), config.HTTPTunnel.Port)) + if strings.TrimSpace(config.HTTPTunnel.User) != "" { + b.WriteString(" HTTP隧道认证=已配置") + } + } if config.Type == "custom" { driver := strings.TrimSpace(config.Driver) diff --git a/internal/app/db_proxy.go b/internal/app/db_proxy.go index bdf2311..e3228b6 100644 --- a/internal/app/db_proxy.go +++ b/internal/app/db_proxy.go @@ -12,8 +12,35 @@ import ( func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { config := raw + if config.UseHTTPTunnel { + if config.UseProxy { + return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道与普通代理不能同时启用") + } + tunnelHost := strings.TrimSpace(config.HTTPTunnel.Host) + if tunnelHost == "" { + return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道主机不能为空") + } + tunnelPort := config.HTTPTunnel.Port + if tunnelPort <= 0 { + tunnelPort = 8080 + } + if tunnelPort > 65535 { + return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道端口无效:%d", config.HTTPTunnel.Port) + } + + config.UseProxy = true + config.Proxy = connection.ProxyConfig{ + Type: "http", + Host: tunnelHost, + Port: tunnelPort, + User: strings.TrimSpace(config.HTTPTunnel.User), + Password: config.HTTPTunnel.Password, + } + } if !config.UseProxy { config.Proxy = connection.ProxyConfig{} + config.UseHTTPTunnel = false + config.HTTPTunnel = connection.HTTPTunnelConfig{} return config, nil } @@ -22,6 +49,8 @@ func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.Con return connection.ConnectionConfig{}, err } config.Proxy = normalizedProxy + config.UseHTTPTunnel = false + config.HTTPTunnel = connection.HTTPTunnelConfig{} if config.UseSSH { sshPort := config.SSH.Port diff --git a/internal/app/global_proxy.go b/internal/app/global_proxy.go index 4dc8686..4361782 100644 --- a/internal/app/global_proxy.go +++ b/internal/app/global_proxy.go @@ -110,7 +110,7 @@ func (a *App) GetGlobalProxyConfig() connection.QueryResult { func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig { effective := config - if effective.UseProxy { + if effective.UseProxy || effective.UseHTTPTunnel { return effective } if isFileDatabaseType(effective.Type) { diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index d1ef4a9..d8529a9 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -547,6 +547,13 @@ func sqlSnippet(query string) string { return q[:max] + "..." } +func ensureNonNilSlice[T any](items []T) []T { + if items == nil { + return make([]T, 0) + } + return items +} + func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult { runConfig := normalizeRunConfig(config, "") dbInst, err := a.getDatabase(runConfig) @@ -571,7 +578,7 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer return connection.QueryResult{Success: false, Message: err.Error()} } - var resData []map[string]string + resData := make([]map[string]string, 0, len(dbs)) for _, name := range dbs { resData = append(resData, map[string]string{"Database": name}) } @@ -604,7 +611,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con return connection.QueryResult{Success: false, Message: err.Error()} } - var resData []map[string]string + resData := make([]map[string]string, 0, len(tables)) for _, name := range tables { resData = append(resData, map[string]string{"Table": name}) } @@ -786,7 +793,7 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: columns} + return connection.QueryResult{Success: true, Data: ensureNonNilSlice(columns)} } func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult { @@ -803,7 +810,7 @@ func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: indexes} + return connection.QueryResult{Success: true, Data: ensureNonNilSlice(indexes)} } func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult { @@ -820,7 +827,7 @@ func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: fks} + return connection.QueryResult{Success: true, Data: ensureNonNilSlice(fks)} } func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult { @@ -837,7 +844,7 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: triggers} + return connection.QueryResult{Success: true, Data: ensureNonNilSlice(triggers)} } func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult { @@ -975,5 +982,5 @@ func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: cols} + return connection.QueryResult{Success: true, Data: ensureNonNilSlice(cols)} } diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 344233e..07a13cc 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -2536,6 +2536,9 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr) } } + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil { + return installedDriverPackage{}, validateErr + } hash, hashErr := hashFileSHA256(executablePath) if hashErr != nil { @@ -2793,11 +2796,15 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut info, err := os.Stat(executablePath) if err == nil && !info.IsDir() { - hash, hashErr := hashFileSHA256(executablePath) - if hashErr != nil { - return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr) + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil { + _ = os.Remove(executablePath) + } else { + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil } - return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil } if err == nil && info.IsDir() { return "", "", fmt.Errorf("%s 驱动代理路径被目录占用:%s", displayName, executablePath) @@ -2814,6 +2821,10 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil { return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr) } + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil { + _ = os.Remove(executablePath) + return "", "", validateErr + } hash, hashErr := hashFileSHA256(executablePath) if hashErr != nil { return "", "", fmt.Errorf("计算预置 %s 驱动代理摘要失败:%w", displayName, hashErr) @@ -2901,6 +2912,10 @@ func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlT if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { return "", fmt.Errorf("设置代理权限失败:%w", chmodErr) } + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil { + _ = os.Remove(executablePath) + return "", validateErr + } return hash, nil } @@ -3009,6 +3024,10 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition, if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr) } + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil { + _ = os.Remove(executablePath) + return "", "", validateErr + } hash, err := hashFileSHA256(executablePath) if err != nil { return "", "", fmt.Errorf("计算驱动代理摘要失败:%w", err) @@ -3334,6 +3353,7 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL } func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targetPath string) (string, bool) { + driverType := normalizeDriverType(definition.Type) targetAbs, _ := filepath.Abs(targetPath) candidates := resolveOptionalDriverAgentCandidatePaths(definition) for _, candidate := range candidates { @@ -3349,9 +3369,13 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe continue } info, statErr := os.Stat(absPath) - if statErr == nil && !info.IsDir() { - return absPath, true + if statErr != nil || info.IsDir() { + continue } + if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, absPath); validateErr != nil { + continue + } + return absPath, true } return "", false } diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 1b626b0..3bf8956 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -23,12 +23,20 @@ var ( // getRedisClient gets or creates a Redis client from cache func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) { - key := getRedisClientCacheKey(config) + effectiveConfig := applyGlobalProxyToConnection(config) + connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) + if proxyErr != nil { + wrapped := wrapConnectError(effectiveConfig, proxyErr) + logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig)) + return nil, wrapped + } + + key := getRedisClientCacheKey(connectConfig) shortKey := key if len(shortKey) > 12 { shortKey = shortKey[:12] } - logger.Infof("获取 Redis 连接:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey) + logger.Infof("获取 Redis 连接:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey) redisCacheMu.Lock() defer redisCacheMu.Unlock() @@ -47,21 +55,20 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey) client := redis.NewRedisClient() - if err := client.Connect(config); err != nil { - logger.Error(err, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey) - return nil, err + if err := client.Connect(connectConfig); err != nil { + wrapped := wrapConnectError(effectiveConfig, err) + logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey) + return nil, wrapped } redisCache[key] = client - logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey) + logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey) return client, nil } func getRedisClientCacheKey(config connection.ConnectionConfig) string { - if !config.UseSSH { - config.SSH = connection.SSHConfig{} - } - b, _ := json.Marshal(config) + normalized := normalizeCacheKeyConfig(config) + b, _ := json.Marshal(normalized) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } @@ -91,6 +98,26 @@ func formatRedisConnSummary(config connection.ConnectionConfig) string { b.WriteString(" 用户=") b.WriteString(config.SSH.User) } + if config.UseProxy { + b.WriteString(" 代理=") + b.WriteString(strings.ToLower(strings.TrimSpace(config.Proxy.Type))) + b.WriteString("://") + b.WriteString(config.Proxy.Host) + b.WriteString(":") + b.WriteString(strconv.Itoa(config.Proxy.Port)) + if strings.TrimSpace(config.Proxy.User) != "" { + b.WriteString(" 代理认证=已配置") + } + } + if config.UseHTTPTunnel { + b.WriteString(" HTTP隧道=") + b.WriteString(strings.TrimSpace(config.HTTPTunnel.Host)) + b.WriteString(":") + b.WriteString(strconv.Itoa(config.HTTPTunnel.Port)) + if strings.TrimSpace(config.HTTPTunnel.User) != "" { + b.WriteString(" HTTP隧道认证=已配置") + } + } return b.String() } diff --git a/internal/connection/types.go b/internal/connection/types.go index bc88873..bac9ec7 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -18,39 +18,49 @@ type ProxyConfig struct { Password string `json:"password,omitempty"` } +// HTTPTunnelConfig holds independent HTTP CONNECT tunnel details +type HTTPTunnelConfig struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` +} + // ConnectionConfig holds database connection details including SSH type ConnectionConfig struct { - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection - Database string `json:"database"` - UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch - SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable - SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) - SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) - UseSSH bool `json:"useSSH"` - SSH SSHConfig `json:"ssh"` - UseProxy bool `json:"useProxy,omitempty"` - Proxy ProxyConfig `json:"proxy,omitempty"` - Driver string `json:"driver,omitempty"` // For custom connection - DSN string `json:"dsn,omitempty"` // For custom connection - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) - RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) - URI string `json:"uri,omitempty"` // Connection URI for copy/paste - Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica | cluster - MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password - ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name - AuthSource string `json:"authSource,omitempty"` // MongoDB authSource - ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference - MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme - MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism - MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection + Database string `json:"database"` + UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch + SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable + SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) + SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) + UseSSH bool `json:"useSSH"` + SSH SSHConfig `json:"ssh"` + UseProxy bool `json:"useProxy,omitempty"` + Proxy ProxyConfig `json:"proxy,omitempty"` + UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` + HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` + Driver string `json:"driver,omitempty"` // For custom connection + DSN string `json:"dsn,omitempty"` // For custom connection + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) + RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) + URI string `json:"uri,omitempty"` // Connection URI for copy/paste + Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port + Topology string `json:"topology,omitempty"` // single | replica | cluster + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password + ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name + AuthSource string `json:"authSource,omitempty"` // MongoDB authSource + ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference + MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password } // QueryResult is the standard response format for Wails methods diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go index dcf18e6..f1d5811 100644 --- a/internal/db/clickhouse_impl.go +++ b/internal/db/clickhouse_impl.go @@ -107,7 +107,9 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig if readTimeout < minClickHouseReadTimeout { readTimeout = minClickHouseReadTimeout } + protocol := detectClickHouseProtocol(config) opts := &clickhouse.Options{ + Protocol: protocol, Addr: []string{ net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), }, @@ -125,6 +127,46 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig return opts } +func detectClickHouseProtocol(config connection.ConnectionConfig) clickhouse.Protocol { + uriText := strings.ToLower(strings.TrimSpace(config.URI)) + if strings.HasPrefix(uriText, "http://") || strings.HasPrefix(uriText, "https://") { + return clickhouse.HTTP + } + if config.Port == 8123 || config.Port == 8443 { + return clickhouse.HTTP + } + return clickhouse.Native +} + +func isClickHouseProtocolMismatch(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(strings.TrimSpace(err.Error())) + if text == "" { + return false + } + return strings.Contains(text, "unexpected packet [72]") || + (strings.Contains(text, "unexpected packet") && strings.Contains(text, "handshake")) || + strings.Contains(text, "http response to https client") || + strings.Contains(text, "malformed http response") +} + +func withClickHouseProtocol(config connection.ConnectionConfig, protocol clickhouse.Protocol) connection.ConnectionConfig { + next := config + switch protocol { + case clickhouse.HTTP: + if next.Port == 0 { + next.Port = 8123 + } + default: + if next.Port == 0 { + next.Port = defaultClickHousePort + } + } + return next +} + func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported { if strings.TrimSpace(reason) == "" { @@ -176,23 +218,41 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { var failures []string for idx, attempt := range attempts { - c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(attempt)) - if err := c.Ping(); err != nil { - failures = append(failures, fmt.Sprintf("第%d次连接验证失败: %v", idx+1, err)) - if c.conn != nil { - _ = c.conn.Close() - c.conn = nil + primaryProtocol := detectClickHouseProtocol(attempt) + protocols := []clickhouse.Protocol{primaryProtocol} + if primaryProtocol == clickhouse.Native { + protocols = append(protocols, clickhouse.HTTP) + } else { + protocols = append(protocols, clickhouse.Native) + } + + for pIdx, protocol := range protocols { + protocolConfig := withClickHouseProtocol(attempt, protocol) + c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(protocolConfig)) + if err := c.Ping(); err != nil { + failures = append(failures, fmt.Sprintf("第%d次连接验证失败(protocol=%s): %v", idx+1, protocol.String(), err)) + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } + if pIdx == 0 && !isClickHouseProtocolMismatch(err) { + // 首次连接不是协议误配特征,避免无谓重试次协议。 + break + } + continue } - continue + if idx > 0 { + logger.Warnf("ClickHouse SSL 优先连接失败,已回退至明文连接") + } + if pIdx > 0 { + logger.Warnf("ClickHouse 已自动切换连接协议为 %s(常见于 8123/8443 HTTP 端口)", protocol.String()) + } + return nil } - if idx > 0 { - logger.Warnf("ClickHouse SSL 优先连接失败,已回退至明文连接") - } - return nil } _ = c.Close() - return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ";")) + return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配:Native=9000/9440,HTTP=8123/8443):%s", strings.Join(failures, ";")) } func (c *ClickHouseDB) Close() error { diff --git a/internal/db/dameng_impl.go b/internal/db/dameng_impl.go index 5080540..5cceb0a 100644 --- a/internal/db/dameng_impl.go +++ b/internal/db/dameng_impl.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/url" + "sort" "strconv" "strings" "time" @@ -204,24 +205,82 @@ func (d *DamengDB) Exec(query string) (int64, error) { } func (d *DamengDB) GetDatabases() ([]string, error) { - // DM: List Users/Schemas - data, _, err := d.Query("SELECT username FROM dba_users") - if err != nil { - // Fallback if dba_users not accessible - data, _, err = d.Query("SELECT username FROM all_users") + // 达梦将「用户/模式」作为数据库列表来源,不同权限下可见口径不同。 + // 这里采用多查询口径聚合,避免仅依赖单一视图导致“少库”。 + queries := []string{ + "SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME", + "SELECT USERNAME AS DATABASE_NAME FROM DBA_USERS ORDER BY USERNAME", + "SELECT USERNAME AS DATABASE_NAME FROM ALL_USERS ORDER BY USERNAME", + "SELECT USERNAME AS DATABASE_NAME FROM USER_USERS", + "SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER", + } + + seen := make(map[string]struct{}) + dbs := make([]string, 0, 64) + var lastErr error + success := false + + for _, q := range queries { + data, _, err := d.Query(q) if err != nil { - return nil, err + lastErr = err + continue + } + success = true + for _, row := range data { + name := getDamengRowString(row, "DATABASE_NAME", "USERNAME", "OWNER", "SCHEMA_NAME") + if name == "" { + // 回退到第一列,兼容驱动返回列名差异。 + for _, v := range row { + text := strings.TrimSpace(fmt.Sprintf("%v", v)) + if text == "" || strings.EqualFold(text, "") { + continue + } + name = text + break + } + } + if name == "" { + continue + } + key := strings.ToUpper(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + dbs = append(dbs, name) } } - var dbs []string - for _, row := range data { - if val, ok := row["USERNAME"]; ok { - dbs = append(dbs, fmt.Sprintf("%v", val)) - } + + if !success && lastErr != nil { + return nil, lastErr } + + sort.Slice(dbs, func(i, j int) bool { + return strings.ToUpper(dbs[i]) < strings.ToUpper(dbs[j]) + }) return dbs, nil } +func getDamengRowString(row map[string]interface{}, keys ...string) string { + if len(row) == 0 { + return "" + } + for _, key := range keys { + for k, v := range row { + if !strings.EqualFold(strings.TrimSpace(k), strings.TrimSpace(key)) { + continue + } + text := strings.TrimSpace(fmt.Sprintf("%v", v)) + if text == "" || strings.EqualFold(text, "") { + return "" + } + return text + } + } + return "" +} + func (d *DamengDB) GetTables(dbName string) ([]string, error) { query := fmt.Sprintf("SELECT owner, table_name FROM all_tables WHERE owner = '%s' ORDER BY table_name", strings.ToUpper(dbName)) if dbName == "" { diff --git a/internal/db/driver_agent_binary_check.go b/internal/db/driver_agent_binary_check.go new file mode 100644 index 0000000..762c720 --- /dev/null +++ b/internal/db/driver_agent_binary_check.go @@ -0,0 +1,74 @@ +package db + +import ( + "debug/pe" + "fmt" + "runtime" + "strings" +) + +const ( + peMachineI386 uint16 = 0x014c + peMachineAmd64 uint16 = 0x8664 + peMachineArm64 uint16 = 0xaa64 +) + +func windowsMachineLabel(machine uint16) string { + switch machine { + case peMachineI386: + return "windows-386" + case peMachineAmd64: + return "windows-amd64" + case peMachineArm64: + return "windows-arm64" + default: + return fmt.Sprintf("windows-unknown(0x%04x)", machine) + } +} + +func expectedWindowsMachineForGoArch(goarch string) (uint16, string, bool) { + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "386": + return peMachineI386, "windows-386", true + case "amd64": + return peMachineAmd64, "windows-amd64", true + case "arm64": + return peMachineArm64, "windows-arm64", true + default: + return 0, "", false + } +} + +func validateWindowsExecutableMachine(pathText string) error { + file, err := pe.Open(pathText) + if err != nil { + return fmt.Errorf("无法识别为有效的 Windows 可执行文件:%w", err) + } + defer file.Close() + + expectedMachine, expectedLabel, ok := expectedWindowsMachineForGoArch(runtime.GOARCH) + if !ok { + return nil + } + actualMachine := file.FileHeader.Machine + if actualMachine != expectedMachine { + return fmt.Errorf("可执行文件架构不兼容(文件=%s,当前进程=%s)", windowsMachineLabel(actualMachine), expectedLabel) + } + return nil +} + +// ValidateOptionalDriverAgentExecutable 校验可选驱动代理二进制是否可在当前进程中执行。 +// 当前主要用于 Windows 下的 PE 架构兼容性校验,避免升级后复用到错误架构的旧代理。 +func ValidateOptionalDriverAgentExecutable(driverType string, executablePath string) error { + pathText := strings.TrimSpace(executablePath) + if pathText == "" { + return fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType)) + } + if runtime.GOOS != "windows" { + return nil + } + if err := validateWindowsExecutableMachine(pathText); err != nil { + return fmt.Errorf("%s 驱动代理不可用:%w", driverDisplayName(driverType), err) + } + return nil +} diff --git a/internal/db/driver_support.go b/internal/db/driver_support.go index 517a81a..db00717 100644 --- a/internal/db/driver_support.go +++ b/internal/db/driver_support.go @@ -194,6 +194,9 @@ func optionalGoDriverRuntimeReady(driverType string) (bool, string) { if statErr != nil || info.IsDir() { return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized)) } + if validateErr := ValidateOptionalDriverAgentExecutable(normalized, executablePath); validateErr != nil { + return false, fmt.Sprintf("%s;请在驱动管理中重新安装启用", validateErr.Error()) + } return true, "" } diff --git a/internal/db/driver_support_test.go b/internal/db/driver_support_test.go index 8dc5f62..002fba0 100644 --- a/internal/db/driver_support_test.go +++ b/internal/db/driver_support_test.go @@ -65,11 +65,22 @@ func TestManagedDriverRequiresInstallMarker(t *testing.T) { if err != nil { t.Fatalf("解析 mariadb 代理路径失败: %v", err) } - if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil { - t.Fatalf("写入 mariadb 代理占位文件失败: %v", err) - } if runtime.GOOS == "windows" { - _ = os.Chmod(executablePath, 0o644) + selfPath, selfErr := os.Executable() + if selfErr != nil { + t.Fatalf("获取测试进程路径失败: %v", selfErr) + } + content, readErr := os.ReadFile(selfPath) + if readErr != nil { + t.Fatalf("读取测试进程失败: %v", readErr) + } + if err := os.WriteFile(executablePath, content, 0o755); err != nil { + t.Fatalf("写入 mariadb 代理占位可执行文件失败: %v", err) + } + } else { + if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil { + t.Fatalf("写入 mariadb 代理占位文件失败: %v", err) + } } supported, reason := DriverRuntimeSupportStatus("mariadb") diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index f1357a8..6dfd2e5 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -623,28 +623,16 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet } defer tx.Rollback() - quoteIdent := func(name string) string { - n := strings.TrimSpace(name) - n = strings.Trim(n, "\"") - n = strings.ReplaceAll(n, "\"", "\"\"") - if n == "" { - return "\"\"" - } - return `"` + n + `"` - } - - schema := "" - table := strings.TrimSpace(tableName) - if parts := strings.SplitN(table, ".", 2); len(parts) == 2 { - schema = strings.TrimSpace(parts[0]) - table = strings.TrimSpace(parts[1]) + schema, table := splitKingbaseQualifiedTable(tableName) + if table == "" { + return fmt.Errorf("table name required") } qualifiedTable := "" if schema != "" { - qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table)) + qualifiedTable = fmt.Sprintf("%s.%s", quoteKingbaseIdent(schema), quoteKingbaseIdent(table)) } else { - qualifiedTable = quoteIdent(table) + qualifiedTable = quoteKingbaseIdent(table) } // 1. Deletes @@ -654,7 +642,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet idx := 0 for k, v := range pk { idx++ - wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx)) + wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx)) args = append(args, v) } if len(wheres) == 0 { @@ -674,7 +662,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet for k, v := range update.Values { idx++ - sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx)) + sets = append(sets, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx)) args = append(args, v) } @@ -685,7 +673,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet var wheres []string for k, v := range update.Keys { idx++ - wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx)) + wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx)) args = append(args, v) } @@ -708,7 +696,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet for k, v := range row { idx++ - cols = append(cols, quoteIdent(k)) + cols = append(cols, quoteKingbaseIdent(k)) placeholders = append(placeholders, fmt.Sprintf("$%d", idx)) args = append(args, v) } @@ -726,6 +714,67 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet return tx.Commit() } +func normalizeKingbaseIdentifier(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + + // 兼容 JSON/字符串转义后传入的标识符:\"schema\" -> "schema" + value = strings.ReplaceAll(value, `\"`, `"`) + value = strings.TrimSpace(value) + + // 兼容异常多重包裹引号(例如 ""schema""、""""schema"""")。 + // strings.Trim 会移除两端连续引号,迭代后可收敛到纯标识符。 + for i := 0; i < 4; i++ { + next := strings.TrimSpace(strings.Trim(value, `"`)) + if next == value { + break + } + value = next + } + + // 兼容其他方言可能残留的引用形式 + if len(value) >= 2 && strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`") { + value = strings.TrimSpace(strings.Trim(value, "`")) + } + if len(value) >= 2 && strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + value = strings.TrimSpace(value[1 : len(value)-1]) + } + + return value +} + +func quoteKingbaseIdent(name string) string { + n := normalizeKingbaseIdentifier(name) + n = strings.ReplaceAll(n, `"`, `""`) + if n == "" { + return "\"\"" + } + return `"` + n + `"` +} + +func splitKingbaseQualifiedTable(tableName string) (schema string, table string) { + raw := strings.TrimSpace(tableName) + if raw == "" { + return "", "" + } + + if parts := strings.SplitN(raw, ".", 2); len(parts) == 2 { + schema = normalizeKingbaseIdentifier(parts[0]) + table = normalizeKingbaseIdentifier(parts[1]) + if table == "" { + return "", normalizeKingbaseIdentifier(raw) + } + if schema == "" { + return "", table + } + return schema, table + } + + return "", normalizeKingbaseIdentifier(raw) +} + func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { // dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。 query := ` diff --git a/internal/db/kingbase_impl_test.go b/internal/db/kingbase_impl_test.go new file mode 100644 index 0000000..eca6eaa --- /dev/null +++ b/internal/db/kingbase_impl_test.go @@ -0,0 +1,74 @@ +//go:build gonavi_full_drivers || gonavi_kingbase_driver + +package db + +import "testing" + +func TestNormalizeKingbaseIdentifier(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "plain", in: "ldf_server", want: "ldf_server"}, + {name: "quoted", in: `"ldf_server"`, want: "ldf_server"}, + {name: "double quoted", in: `""ldf_server""`, want: "ldf_server"}, + {name: "quad quoted", in: `""""ldf_server""""`, want: "ldf_server"}, + {name: "escaped quoted", in: `\"ldf_server\"`, want: "ldf_server"}, + {name: "backtick quoted", in: "`ldf_server`", want: "ldf_server"}, + {name: "bracket quoted", in: "[ldf_server]", want: "ldf_server"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeKingbaseIdentifier(tt.in); got != tt.want { + t.Fatalf("normalizeKingbaseIdentifier(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestQuoteKingbaseIdent(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "plain", in: "ldf_server", want: `"ldf_server"`}, + {name: "double quoted", in: `""ldf_server""`, want: `"ldf_server"`}, + {name: "escaped quoted", in: `\"ldf_server\"`, want: `"ldf_server"`}, + {name: "with embedded quote", in: `ab"cd`, want: `"ab""cd"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := quoteKingbaseIdent(tt.in); got != tt.want { + t.Fatalf("quoteKingbaseIdent(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSplitKingbaseQualifiedTable(t *testing.T) { + tests := []struct { + name string + in string + wantSchema string + wantTable string + }{ + {name: "plain qualified", in: "ldf_server.t_user", wantSchema: "ldf_server", wantTable: "t_user"}, + {name: "double quoted qualified", in: `""ldf_server"".""t_user""`, wantSchema: "ldf_server", wantTable: "t_user"}, + {name: "escaped qualified", in: `\"ldf_server\".\"t_user\"`, wantSchema: "ldf_server", wantTable: "t_user"}, + {name: "bracket qualified", in: "[ldf_server].[t_user]", wantSchema: "ldf_server", wantTable: "t_user"}, + {name: "table only", in: `""t_user""`, wantSchema: "", wantTable: "t_user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSchema, gotTable := splitKingbaseQualifiedTable(tt.in) + if gotSchema != tt.wantSchema || gotTable != tt.wantTable { + t.Fatalf("splitKingbaseQualifiedTable(%q) = (%q, %q), want (%q, %q)", tt.in, gotSchema, gotTable, tt.wantSchema, tt.wantTable) + } + }) + } +} diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index 1b83902..2579b7c 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -9,8 +9,10 @@ import ( "io" "os" "os/exec" + "runtime" "strings" "sync" + "syscall" "time" "GoNavi-Wails/internal/connection" @@ -94,6 +96,9 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op return nil, fmt.Errorf("创建 %s 驱动代理 stderr 失败:%w", driverDisplayName(driverType), err) } if err := cmd.Start(); err != nil { + if isWindowsExecutableMachineMismatch(err) { + return nil, fmt.Errorf("启动 %s 驱动代理失败:%w(检测到驱动代理与当前系统架构不兼容,请在驱动管理中重新安装启用)", driverDisplayName(driverType), err) + } return nil, fmt.Errorf("启动 %s 驱动代理失败:%w", driverDisplayName(driverType), err) } @@ -107,6 +112,30 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op return client, nil } +func isWindowsExecutableMachineMismatch(err error) bool { + if err == nil || runtime.GOOS != "windows" { + return false + } + var errno syscall.Errno + if errors.As(err, &errno) && errno == syscall.Errno(216) { + return true + } + text := strings.ToLower(strings.TrimSpace(err.Error())) + if text == "" { + return false + } + if strings.Contains(text, "not compatible with the version of windows") { + return true + } + if strings.Contains(text, "win32") && strings.Contains(text, "compatible") { + return true + } + if strings.Contains(text, "不是有效的win32应用程序") || strings.Contains(text, "无法在win32模式下运行") { + return true + } + return false +} + func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) { scanner := bufio.NewScanner(stderr) buffer := make([]byte, 0, 8<<10) diff --git a/internal/db/query_value.go b/internal/db/query_value.go index 83fdf7f..fa28bd7 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -8,6 +8,7 @@ import ( "reflect" "strconv" "strings" + "time" "unicode" "unicode/utf8" ) @@ -86,6 +87,16 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { items[i] = normalizeQueryValue(rv.Index(i).Interface()) } return items + case reflect.Struct: + // 部分驱动(如 Kingbase)会返回复杂结构体值,直接透传会导致前端渲染和比较开销激增。 + // 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。 + if tm, ok := v.(time.Time); ok { + return tm.Format(time.RFC3339Nano) + } + if stringer, ok := v.(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprintf("%v", v) default: return normalizeUnsafeIntegerForJS(rv, v) } diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index b05977e..285344e 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -2,7 +2,9 @@ package db import ( "encoding/json" + "fmt" "testing" + "time" ) type duckMapLike map[any]any @@ -165,3 +167,31 @@ func TestNormalizeQueryValueWithDBType_JSONNumber(t *testing.T) { }) } } + +type customStructValue struct { + Name string + Age int +} + +func (v customStructValue) String() string { + return fmt.Sprintf("%s-%d", v.Name, v.Age) +} + +func TestNormalizeQueryValueWithDBType_StructToString(t *testing.T) { + got := normalizeQueryValueWithDBType(customStructValue{Name: "alice", Age: 18}, "") + if got != "alice-18" { + t.Fatalf("结构体应降级为可读字符串,实际=%v(%T)", got, got) + } +} + +func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) { + input := time.Date(2026, 3, 5, 18, 30, 15, 123456789, time.UTC) + got := normalizeQueryValueWithDBType(input, "") + text, ok := got.(string) + if !ok { + t.Fatalf("time.Time 应转为字符串,实际=%v(%T)", got, got) + } + if text != "2026-03-05T18:30:15.123456789Z" { + t.Fatalf("time.Time 规整值异常,实际=%s", text) + } +} From fb65b553e984a12ec8414691314994f4440ae5a8 Mon Sep 17 00:00:00 2001 From: Syngnat <92659908+Syngnat@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:30:07 +0800 Subject: [PATCH 2/4] Release/0.5.3 (#191) --- .github/release.yaml | 26 ++++++++++++++++++++++++++ .github/workflows/release.yml | 1 + README.md | 1 + README.zh-CN.md | 1 + 4 files changed, 29 insertions(+) create mode 100644 .github/release.yaml diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..5c87acd --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,26 @@ +changelog: + categories: + - title: 新功能 + labels: + - feature + - enhancement + - feat + - title: 问题修复 + labels: + - bug + - fix + - title: 文档与流程 + labels: + - docs + - documentation + - ci + - workflow + - chore + - title: 重构与优化 + labels: + - refactor + - perf + - optimization + - title: 其他更新 + labels: + - '*' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4e6a37..7dd9b87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -550,5 +550,6 @@ jobs: files: release-assets/* draft: true make_latest: true + generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 4ad80ac..c2ad140 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Artifacts are generated in `build/bin`. The repository includes a release workflow. Push a `v*` tag to trigger automated build and release. +Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`. Target artifacts include: - macOS (AMD64 / ARM64) diff --git a/README.zh-CN.md b/README.zh-CN.md index 3a2f2d5..6c74566 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,7 @@ wails build -clean ### 跨平台发布(GitHub Actions) 仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。 +Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。 支持目标: - macOS (AMD64 / ARM64) From 75a5a322e03487d466a92059d5db5eff13e435e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=A3=E6=9D=A1?= <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:32:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?-=20chore(ci):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=A8=E5=B9=B3=E5=8F=B0=E6=B5=8B=E8=AF=95=E5=8C=85=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=9E=84=E5=BB=BA=E5=B7=A5=E4=BD=9C=E6=B5=81=20tianqi?= =?UTF-8?q?jiuyun-latiao=20=E4=BB=8A=E5=A4=A9=20=E4=B8=8B=E5=8D=884:26=20(?= =?UTF-8?q?#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源 refs #168 * fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销 refs #178 * fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误 refs #176 * fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败 refs #177 * chore(ci): 新增手动触发的 macOS 测试构建工作流 * chore(ci): 允许测试工作流在当前分支自动触发 * fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 * feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 * fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 * fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 * fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 * fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 * fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178 * fix(query-execution): 支持带前置注释的读查询结果识别 * chore(ci): 新增全平台测试包手动构建工作流 --- .../workflows/test-build-all-platforms.yml | 342 ++++++++++++++++++ frontend/src/components/DataGrid.tsx | 9 +- internal/app/methods_db.go | 13 +- internal/app/sql_sanitize.go | 60 +++ internal/db/kingbase_impl.go | 82 ++++- 5 files changed, 482 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/test-build-all-platforms.yml diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml new file mode 100644 index 0000000..3fccb8d --- /dev/null +++ b/.github/workflows/test-build-all-platforms.yml @@ -0,0 +1,342 @@ +name: Test Build All Platforms (Manual) + +on: + workflow_dispatch: + inputs: + build_label: + description: "测试包标识(仅用于文件名)" + required: false + default: "test" + +permissions: + contents: read + +concurrency: + group: test-build-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin/amd64 + os_name: MacOS + arch_name: Amd64 + build_name: gonavi-test-darwin-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: macos-latest + platform: darwin/arm64 + os_name: MacOS + arch_name: Arm64 + build_name: gonavi-test-darwin-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/amd64 + os_name: Windows + arch_name: Amd64 + build_name: gonavi-test-windows-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/arm64 + os_name: Windows + arch_name: Arm64 + build_name: gonavi-test-windows-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: ubuntu-22.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-test-linux-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "4.0" + - os: ubuntu-24.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-test-linux-amd64-webkit41 + wails_tags: "webkit2_41" + artifact_suffix: "-WebKit41" + build_optional_agents: false + linux_webkit: "4.1" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + check-latest: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Linux Dependencies + if: contains(matrix.platform, 'linux') + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev + + if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then + sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev + else + sudo apt-get install -y libwebkit2gtk-4.0-dev + fi + + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + + LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" + PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage" + + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || { + echo "skip-appimage=true" >> "$GITHUB_ENV" + } + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || { + echo "skip-appimage=true" >> "$GITHUB_ENV" + } + + if [ "${skip-appimage:-false}" != "true" ]; then + chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk + fi + + - name: Install Wails + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + + - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) + id: msys2_duckdb + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + continue-on-error: true + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-gcc + + - name: Configure DuckDB CGO Toolchain (Windows AMD64) + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + shell: pwsh + run: | + function Find-MingwBin([string[]]$candidates) { + foreach ($bin in $candidates) { + if ([string]::IsNullOrWhiteSpace($bin)) { + continue + } + $gcc = Join-Path $bin 'gcc.exe' + $gxx = Join-Path $bin 'g++.exe' + if ((Test-Path $gcc) -and (Test-Path $gxx)) { + return $bin + } + } + return $null + } + + $msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}" + $candidateBins = @() + if (-not [string]::IsNullOrWhiteSpace($msys2Location)) { + $candidateBins += Join-Path $msys2Location 'ucrt64\bin' + } + $candidateBins += @( + 'C:\msys64\ucrt64\bin', + 'D:\a\_temp\msys64\ucrt64\bin' + ) + $candidateBins = @($candidateBins | Select-Object -Unique) + + $mingwBin = Find-MingwBin $candidateBins + if (-not $mingwBin) { + Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。" + exit 1 + } + + $gcc = Join-Path $mingwBin 'gcc.exe' + $gxx = Join-Path $mingwBin 'g++.exe' + "$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + "CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build App + shell: bash + run: | + set -euo pipefail + BUILD_LABEL="${{ inputs.build_label }}" + if [ -z "$BUILD_LABEL" ]; then + BUILD_LABEL="test" + fi + APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" + if [ -n "${{ matrix.wails_tags }}" ]; then + wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" + else + wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" + fi + + - name: Build Optional Driver Agents + if: ${{ matrix.build_optional_agents }} + shell: bash + run: | + set -euo pipefail + TARGET_PLATFORM="${{ matrix.platform }}" + GOOS="${TARGET_PLATFORM%%/*}" + GOARCH="${TARGET_PLATFORM##*/}" + DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse) + OUTDIR="drivers/${{ matrix.os_name }}" + mkdir -p "$OUTDIR" + + for DRIVER in "${DRIVERS[@]}"; do + BUILD_DRIVER="$DRIVER" + if [ "$DRIVER" = "doris" ]; then + BUILD_DRIVER="diros" + fi + if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then + echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}" + continue + fi + TAG="gonavi_${BUILD_DRIVER}_driver" + OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + if [ "$DRIVER" = "duckdb" ]; then + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent + else + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent + fi + done + + - name: Package macOS + if: contains(matrix.platform, 'darwin') + shell: bash + run: | + set -euo pipefail + brew install create-dmg + LABEL="${{ inputs.build_label }}" + if [ -z "$LABEL" ]; then + LABEL="test" + fi + cd build/bin + APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "未找到 .app 应用包" + exit 1 + fi + APP_NAME=$(basename "$APP_PATH") + codesign --force --deep --sign - "$APP_NAME" + ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip" + DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg" + mkdir -p ../../artifacts + ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME" + create-dmg \ + --volname "GoNavi Test Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "$APP_NAME" 200 190 \ + --hide-extension "$APP_NAME" \ + --app-drop-link 600 185 \ + "$DMG_NAME" \ + "$APP_NAME" + mv "$DMG_NAME" "../../artifacts/$DMG_NAME" + shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256" + shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256" + + - name: Package Windows + if: contains(matrix.platform, 'windows') + shell: pwsh + run: | + $label = "${{ inputs.build_label }}" + if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' } + Set-Location build/bin + $target = "${{ matrix.build_name }}" + $finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe" + $finalZipName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.zip" + if (Test-Path "$target.exe") { + $finalExe = "$target.exe" + } elseif (Test-Path "$target") { + Rename-Item -Path "$target" -NewName "$target.exe" + $finalExe = "$target.exe" + } else { + Write-Error "未找到构建产物 '$target'" + exit 1 + } + New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null + Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force + Compress-Archive -LiteralPath $finalExe -DestinationPath "..\..\artifacts\$finalZipName" -Force + Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii + Get-FileHash "..\..\artifacts\$finalZipName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalZipName.sha256" -Encoding ascii + + - name: Package Linux + if: contains(matrix.platform, 'linux') + shell: bash + run: | + set -euo pipefail + LABEL="${{ inputs.build_label }}" + if [ -z "$LABEL" ]; then + LABEL="test" + fi + cd build/bin + TARGET="${{ matrix.build_name }}" + TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz" + APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage" + mkdir -p ../../artifacts + + if [ ! -f "$TARGET" ]; then + echo "未找到构建产物 '$TARGET'" + exit 1 + fi + chmod +x "$TARGET" + tar -czvf "../../artifacts/$TAR_NAME" "$TARGET" + sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256" + + if [ "${skip-appimage:-false}" = "true" ]; then + echo "跳过 AppImage 打包" + exit 0 + fi + + mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps + cp "$TARGET" AppDir/usr/bin/gonavi + printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop + cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop + if [ -f "../../build/appicon.png" ]; then + cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + cp "../../build/appicon.png" AppDir/gonavi.png + else + touch AppDir/gonavi.png + cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + fi + export DEPLOY_GTK_VERSION=3 + /tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0 + mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0 + mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME" + sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256" + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: test-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${{ github.run_number }} + path: | + artifacts/* + drivers/** + if-no-files-found: error + retention-days: 7 diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 981173c..10c8b87 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2074,9 +2074,14 @@ const DataGrid: React.FC = ({ const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1); const enableLargeResultOptimizedEditing = - viewMode === 'table' && (mergedDisplayData.length >= 60 || estimatedVisibleCellCount >= 4000); + viewMode === 'table' && ( + mergedDisplayData.length >= 60 || + estimatedVisibleCellCount >= 1600 || + columnNames.length >= 36 || + (isMacLike && columnNames.length >= 24) + ); const enableVirtual = enableLargeResultOptimizedEditing; - const enableInlineEditableCell = canModifyData; + const enableInlineEditableCell = canModifyData && !enableLargeResultOptimizedEditing; const columns = useMemo(() => { return columnNames.map(key => ({ diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index d8529a9..24119e1 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -416,12 +416,7 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin a.queryMu.Unlock() }() - lowerQuery := strings.TrimSpace(strings.ToLower(query)) - isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") - // MongoDB JSON 命令中的 find/count/aggregate 也属于读查询 - if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") { - isReadQuery = true - } + isReadQuery := isReadOnlySQLQuery(runConfig.Type, query) runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) { if q, ok := inst.(interface { @@ -500,11 +495,7 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second) defer cancel() - lowerQuery := strings.TrimSpace(strings.ToLower(query)) - isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") - if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") { - isReadQuery = true - } + isReadQuery := isReadOnlySQLQuery(runConfig.Type, query) if isReadQuery { var data []map[string]interface{} diff --git a/internal/app/sql_sanitize.go b/internal/app/sql_sanitize.go index 99c5335..2990bcc 100644 --- a/internal/app/sql_sanitize.go +++ b/internal/app/sql_sanitize.go @@ -5,6 +5,66 @@ import ( "unicode" ) +func leadingSQLKeyword(query string) string { + text := strings.TrimSpace(query) + for len(text) > 0 { + trimmed := strings.TrimLeft(text, " \t\r\n") + if trimmed == "" { + return "" + } + text = trimmed + + switch { + case strings.HasPrefix(text, "--"): + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[idx+1:] + continue + } + return "" + case strings.HasPrefix(text, "#"): + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[idx+1:] + continue + } + return "" + case strings.HasPrefix(text, "/*"): + if idx := strings.Index(text, "*/"); idx >= 0 { + text = text[idx+2:] + continue + } + return "" + } + break + } + + if text == "" { + return "" + } + for i, r := range text { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { + continue + } + if i == 0 { + return "" + } + return strings.ToLower(text[:i]) + } + return strings.ToLower(text) +} + +func isReadOnlySQLQuery(dbType string, query string) bool { + if strings.ToLower(strings.TrimSpace(dbType)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") { + return true + } + + switch leadingSQLKeyword(query) { + case "select", "with", "show", "describe", "desc", "explain", "pragma", "values": + return true + default: + return false + } +} + func sanitizeSQLForPgLike(dbType string, query string) string { switch strings.ToLower(strings.TrimSpace(dbType)) { case "postgres", "kingbase", "highgo", "vastbase": diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index 6dfd2e5..619455d 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -305,10 +305,30 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe return strings.ReplaceAll(s, "'", "''") } - query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_schema = '%s' AND table_name = '%s' - ORDER BY ordinal_position`, esc(schema), esc(table)) + query := fmt.Sprintf(` +SELECT + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, + pg_get_expr(ad.adbin, ad.adrelid) AS column_default, + col_description(a.attrelid, a.attnum) AS comment, + CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum +LEFT JOIN ( + SELECT i.indrelid, a3.attname + FROM pg_index i + JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) + WHERE i.indisprimary +) pk ON pk.indrelid = c.oid AND pk.attname = a.attname +WHERE c.relkind IN ('r', 'p') + AND n.nspname = '%s' + AND c.relname = '%s' + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY a.attnum`, esc(schema), esc(table)) data, _, err := k.Query(query) if err != nil { @@ -321,11 +341,21 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), Nullable: fmt.Sprintf("%v", row["is_nullable"]), + Key: fmt.Sprintf("%v", row["column_key"]), + Extra: "", + Comment: "", } if row["column_default"] != nil { def := fmt.Sprintf("%v", row["column_default"]) col.Default = &def + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { + col.Extra = "auto_increment" + } + } + + if v, ok := row["comment"]; ok && v != nil { + col.Comment = fmt.Sprintf("%v", v) } columns = append(columns, col) @@ -347,10 +377,30 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection } // 使用 current_schema() 获取当前schema - query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_schema = current_schema() AND table_name = '%s' - ORDER BY ordinal_position`, esc(table)) + query := fmt.Sprintf(` +SELECT + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, + pg_get_expr(ad.adbin, ad.adrelid) AS column_default, + col_description(a.attrelid, a.attnum) AS comment, + CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum +LEFT JOIN ( + SELECT i.indrelid, a3.attname + FROM pg_index i + JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) + WHERE i.indisprimary +) pk ON pk.indrelid = c.oid AND pk.attname = a.attname +WHERE c.relkind IN ('r', 'p') + AND n.nspname = current_schema() + AND c.relname = '%s' + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY a.attnum`, esc(table)) data, _, err := k.Query(query) if err != nil { @@ -363,11 +413,21 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), Nullable: fmt.Sprintf("%v", row["is_nullable"]), + Key: fmt.Sprintf("%v", row["column_key"]), + Extra: "", + Comment: "", } if row["column_default"] != nil { def := fmt.Sprintf("%v", row["column_default"]) col.Default = &def + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { + col.Extra = "auto_increment" + } + } + + if v, ok := row["comment"]; ok && v != nil { + col.Comment = fmt.Sprintf("%v", v) } columns = append(columns, col) @@ -650,7 +710,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet } query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND ")) if _, err := tx.Exec(query, args...); err != nil { - return fmt.Errorf("delete error: %v", err) + return fmt.Errorf("delete error: %v; sql=%s", err, query) } } @@ -683,7 +743,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND ")) if _, err := tx.Exec(query, args...); err != nil { - return fmt.Errorf("update error: %v", err) + return fmt.Errorf("update error: %v; sql=%s", err, query) } } @@ -707,7 +767,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", ")) if _, err := tx.Exec(query, args...); err != nil { - return fmt.Errorf("insert error: %v", err) + return fmt.Errorf("insert error: %v; sql=%s", err, query) } } From 2b190e564f622d73293f0c70a08b6843078b4cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=A3=E6=9D=A1?= <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:40:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(multi-db,query,ci):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E3=80=81=E6=9F=A5=E8=AF=A2=E4=BD=93=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E5=85=A8=E5=B9=B3=E5=8F=B0=E6=B5=8B=E8=AF=95=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源 refs #168 * fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销 refs #178 * fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误 refs #176 * fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败 refs #177 * chore(ci): 新增手动触发的 macOS 测试构建工作流 * chore(ci): 允许测试工作流在当前分支自动触发 * fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 * feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 * fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 * fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 * fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 * fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 * fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178 * fix(query-execution): 支持带前置注释的读查询结果识别 * chore(ci): 新增全平台测试包手动构建工作流 * fix(ci): 修复全平台测试包 artifact 命名冲突 * fix(data-viewer): 保持切换标签后的表格滚动位置 * fix(datetime-display): 修复零日期显示被错误转换 refs #189 * fix(window-scale): 修复任务栏切换后字体异常放大 refs #193 * fix(data-grid-scroll): 修复数据区触摸板横向滚动失效 refs #175 * fix(db-query-value): 清理 query_value 合并冲突并保持零日期处理 * chore(ci): 删除旧的 macOS 单平台测试工作流 --- .../workflows/test-build-all-platforms.yml | 2 +- .github/workflows/test-macos-build.yml | 91 ---------- frontend/src/App.tsx | 47 ++++- frontend/src/components/DataGrid.tsx | 164 +++++++++++++++++- frontend/src/components/DataViewer.tsx | 74 ++++++-- internal/db/query_value.go | 36 +++- internal/db/query_value_test.go | 30 ++++ 7 files changed, 334 insertions(+), 110 deletions(-) delete mode 100644 .github/workflows/test-macos-build.yml diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml index 3fccb8d..29ffe9d 100644 --- a/.github/workflows/test-build-all-platforms.yml +++ b/.github/workflows/test-build-all-platforms.yml @@ -334,7 +334,7 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: test-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${{ github.run_number }} + name: test-build-${{ matrix.build_name }}-run${{ github.run_number }} path: | artifacts/* drivers/** diff --git a/.github/workflows/test-macos-build.yml b/.github/workflows/test-macos-build.yml deleted file mode 100644 index 1dd01af..0000000 --- a/.github/workflows/test-macos-build.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Test Build macOS (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - push: - branches: - - feature/kingbase_opt - paths: - - ".github/workflows/test-macos-build.yml" - -permissions: - contents: read - -jobs: - build-macos: - name: Build macOS ${{ matrix.arch }} - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - include: - - platform: darwin/amd64 - arch: amd64 - - platform: darwin/arm64 - arch: arm64 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.24.3" - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Build App - run: | - set -euo pipefail - OUTPUT_NAME="gonavi-test-${{ matrix.arch }}" - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - wails build \ - -platform "${{ matrix.platform }}" \ - -clean \ - -o "$OUTPUT_NAME" \ - -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - - - name: Package Zip - run: | - set -euo pipefail - APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app" - if [ ! -d "$APP_PATH" ]; then - APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true) - fi - if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then - echo "未找到 .app 产物" - ls -la build/bin || true - exit 1 - fi - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip" - mkdir -p artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME" - shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }} - path: artifacts/* - if-no-files-found: error diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be49c41..f1cd093 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -283,6 +283,7 @@ function App() { let inFlight = false; let lastRatio = Number(window.devicePixelRatio) || 1; let lastFixAt = 0; + let activationTimer: number | null = null; const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); @@ -334,17 +335,55 @@ function App() { void fixWindowScaleIfNeeded(); }; + const scheduleActivationFix = () => { + if (cancelled) return; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } + activationTimer = window.setTimeout(() => { + activationTimer = null; + if (cancelled) return; + void fixWindowScaleIfNeeded(); + }, 80); + }; + + const handleWindowFocus = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handleVisibilityChange = () => { + if (cancelled) return; + if (document.visibilityState !== 'visible') { + return; + } + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handlePageShow = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + const pollTimer = window.setInterval(checkDevicePixelRatio, 900); window.addEventListener('resize', checkDevicePixelRatio); - window.addEventListener('focus', checkDevicePixelRatio); - document.addEventListener('visibilitychange', checkDevicePixelRatio); + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('pageshow', handlePageShow); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } window.clearInterval(pollTimer); window.removeEventListener('resize', checkDevicePixelRatio); - window.removeEventListener('focus', checkDevicePixelRatio); - document.removeEventListener('visibilitychange', checkDevicePixelRatio); + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('pageshow', handlePageShow); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 10c8b87..088bc14 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -610,6 +610,8 @@ interface DataGridProps { exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; appliedFilterConditions?: FilterCondition[]; + scrollSnapshot?: { top: number; left: number }; + onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; } type GridFilterCondition = FilterCondition & { @@ -629,7 +631,8 @@ type ColumnMeta = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, + scrollSnapshot, onScrollSnapshotChange }) => { const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); @@ -750,6 +753,8 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const pendingScrollToBottomRef = useRef(false); + const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 }); + const didRestoreScrollRef = useRef(false); // 批量编辑模式状态 const [cellEditMode, setCellEditMode] = useState(false); @@ -2767,6 +2772,13 @@ const DataGrid: React.FC = ({ return active ? [active] : []; }, []); + const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => { + const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const body = tableContainer.querySelector('.ant-table-body') as HTMLElement | null; + return virtualHolder || rcVirtualHolder || body; + }, []); + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHScrollRef.current; if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { @@ -2849,12 +2861,162 @@ const DataGrid: React.FC = ({ }; }, [horizontalScrollVisible]); + // 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 + // 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body,导致只能在表头/底部滚动条区域滚动。 + useEffect(() => { + if (viewMode !== 'table' || enableVirtual) return; + const container = tableContainerRef.current; + if (!(container instanceof HTMLElement)) return; + + const resolveHorizontalDelta = (event: WheelEvent) => { + if (Math.abs(event.deltaX) > 0.5) { + return event.deltaX; + } + if (event.shiftKey && Math.abs(event.deltaY) > 0.5) { + return event.deltaY; + } + return 0; + }; + + const isTableDataAreaTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + if (!element) return false; + if (element.closest('.data-grid-external-hscroll')) return false; + return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody'); + }; + + const handleContainerHorizontalWheel = (event: WheelEvent) => { + const horizontalDelta = resolveHorizontalDelta(event); + if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return; + if (!isTableDataAreaTarget(event.target)) return; + + const targets = pickHorizontalScrollTargets(container); + const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; + if (!(activeTarget instanceof HTMLElement)) return; + + const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); + if (maxScrollLeft <= 0) return; + + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); + if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return; + + event.preventDefault(); + event.stopPropagation(); + + horizontalSyncSourceRef.current = 'table'; + activeTarget.scrollLeft = nextScrollLeft; + lastTableScrollLeftRef.current = nextScrollLeft; + + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + horizontalSyncSourceRef.current = ''; + }; + + container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true }); + return () => { + container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); + }; + }, [viewMode, enableVirtual, pickHorizontalScrollTargets]); + useEffect(() => { if (viewMode !== 'table') return; const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); + useEffect(() => { + if (viewMode !== 'table' || !onScrollSnapshotChange) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + + let rafId: number | null = null; + let boundVerticalTarget: HTMLElement | null = null; + let boundHorizontalTargets: HTMLElement[] = []; + const externalScroll = externalHScrollRef.current; + const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5); + + const emitSnapshot = () => { + if (!didRestoreScrollRef.current && hasStoredScroll) { + return; + } + const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer); + const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer); + const top = verticalTarget ? verticalTarget.scrollTop : 0; + const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0; + if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) { + return; + } + lastReportedScrollRef.current = { top, left }; + onScrollSnapshotChange({ top, left }); + }; + + const bindTargets = () => { + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + + boundVerticalTarget = pickVerticalScrollTarget(tableContainer); + boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer); + + boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true }); + boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true })); + externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true }); + emitSnapshot(); + }; + + rafId = requestAnimationFrame(bindTargets); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + }; + }, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]); + + useEffect(() => { + if (viewMode !== 'table') return; + if (!scrollSnapshot) return; + if (didRestoreScrollRef.current) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + if (mergedDisplayData.length === 0) return; + + let rafId = requestAnimationFrame(() => { + const verticalTarget = pickVerticalScrollTarget(tableContainer); + const horizontalTargets = pickHorizontalScrollTargets(tableContainer); + const nextTop = Math.max(0, scrollSnapshot.top); + const nextLeft = Math.max(0, scrollSnapshot.left); + if (verticalTarget && Math.abs(verticalTarget.scrollTop - scrollSnapshot.top) > 1) { + verticalTarget.scrollTop = nextTop; + } + if (Math.abs(nextLeft) > 0.5) { + horizontalTargets.forEach(target => { + if (Math.abs(target.scrollLeft - nextLeft) > 1) { + target.scrollLeft = nextLeft; + } + }); + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextLeft) > 1) { + externalScroll.scrollLeft = nextLeft; + } + lastTableScrollLeftRef.current = nextLeft; + lastExternalScrollLeftRef.current = nextLeft; + } + lastReportedScrollRef.current = { top: nextTop, left: nextLeft }; + didRestoreScrollRef.current = true; + onScrollSnapshotChange?.({ top: nextTop, left: nextLeft }); + }); + + return () => cancelAnimationFrame(rafId); + }, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); + // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 useEffect(() => { if (viewMode !== 'table' || !enableVirtual) return; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index adc2240..597523e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => { type ViewerFilterSnapshot = { showFilter: boolean; conditions: FilterCondition[]; + currentPage: number; + pageSize: number; + sortInfo: { columnKey: string, order: string } | null; + scrollTop: number; + scrollLeft: number; +}; + +type ViewerScrollSnapshot = { + top: number; + left: number; }; const viewerFilterSnapshotsByTab = new Map(); @@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); if (!cached) { - return { showFilter: false, conditions: [] }; + return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 }; } return { showFilter: cached.showFilter === true, conditions: normalizeViewerFilterConditions(cached.conditions), + currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1, + pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100, + sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend') + ? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order } + : null, + scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0, + scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0, }; }; const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { + const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]); const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); const [pkColumns, setPkColumns] = useState([]); @@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const latestDbNameRef = useRef(''); const latestCountSqlRef = useRef(''); const latestCountKeyRef = useRef(''); + const scrollSnapshotRef = useRef({ + top: initialViewerSnapshot.scrollTop, + left: initialViewerSnapshot.scrollLeft, + }); + const initialLoadRef = useRef(false); const [pagination, setPagination] = useState({ - current: 1, - pageSize: 100, + current: initialViewerSnapshot.currentPage, + pageSize: initialViewerSnapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, @@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { totalCountCancelled: false, }); - const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); + const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo); - const [showFilter, setShowFilter] = useState(() => getViewerFilterSnapshot(tab.id).showFilter); - const [filterConditions, setFilterConditions] = useState(() => getViewerFilterSnapshot(tab.id).conditions); + const [showFilter, setShowFilter] = useState(initialViewerSnapshot.showFilter); + const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); @@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const snapshot = getViewerFilterSnapshot(tab.id); setShowFilter(snapshot.showFilter); setFilterConditions(snapshot.conditions); + setSortInfo(snapshot.sortInfo); + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; }, [tab.id]); useEffect(() => { viewerFilterSnapshotsByTab.set(tab.id, { showFilter, conditions: normalizeViewerFilterConditions(filterConditions), + currentPage: pagination.current, + pageSize: pagination.pageSize, + sortInfo, + scrollTop: scrollSnapshotRef.current.top, + scrollLeft: scrollSnapshotRef.current.left, }); - }, [tab.id, showFilter, filterConditions]); + }, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); useEffect(() => { + const snapshot = getViewerFilterSnapshot(tab.id); setPkColumns([]); pkKeyRef.current = ''; countKeyRef.current = ''; @@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { latestDbNameRef.current = ''; latestCountSqlRef.current = ''; latestCountKeyRef.current = ''; + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; setPagination(prev => ({ ...prev, - current: 1, + current: snapshot.currentPage, + pageSize: snapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, totalCountLoading: false, totalCountCancelled: false, })); - }, [tab.connectionId, tab.dbName, tab.tableName]); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName]); + + const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { + scrollSnapshotRef.current = snapshot; + const cached = getViewerFilterSnapshot(tab.id); + viewerFilterSnapshotsByTab.set(tab.id, { + ...cached, + scrollTop: snapshot.top, + scrollLeft: snapshot.left, + }); + }, [tab.id]); const handleDuckDBManualCount = useCallback(async () => { if (latestDbTypeRef.current !== 'duckdb') { @@ -765,8 +810,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); useEffect(() => { - fetchData(1, pagination.pageSize); - }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter + if (!initialLoadRef.current) { + initialLoadRef.current = true; + fetchData(pagination.current, pagination.pageSize); + return; + } + fetchData(1, pagination.pageSize); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter return (
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { readOnly={forceReadOnly} sortInfoExternal={sortInfo} exportSqlWithFilter={exportSqlWithFilter || undefined} + scrollSnapshot={scrollSnapshotRef.current} + onScrollSnapshotChange={handleTableScrollSnapshotChange} />
); diff --git a/internal/db/query_value.go b/internal/db/query_value.go index fa28bd7..24388e5 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -1,4 +1,4 @@ -package db +package db import ( "encoding/hex" @@ -31,12 +31,44 @@ func normalizeQueryValue(v interface{}) interface{} { } func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} { + if tm, ok := v.(time.Time); ok { + return normalizeTemporalValueForDisplay(tm, databaseTypeName) + } if b, ok := v.([]byte); ok { return bytesToDisplayValue(b, databaseTypeName) } return normalizeCompositeQueryValue(v) } +func normalizeTemporalValueForDisplay(value time.Time, databaseTypeName string) interface{} { + if value.IsZero() { + if zeroValue, ok := zeroTemporalDisplayValue(databaseTypeName); ok { + return zeroValue + } + } + return value.Format(time.RFC3339Nano) +} + +func zeroTemporalDisplayValue(databaseTypeName string) (string, bool) { + typeName := strings.ToUpper(strings.TrimSpace(databaseTypeName)) + if typeName == "" { + return "0000-00-00 00:00:00", true + } + + switch { + case strings.Contains(typeName, "TIMESTAMP") || strings.Contains(typeName, "DATETIME"): + return "0000-00-00 00:00:00", true + case typeName == "DATE" || typeName == "NEWDATE": + return "0000-00-00", true + case strings.Contains(typeName, "TIME"): + return "00:00:00", true + case strings.Contains(typeName, "YEAR"): + return "0000", true + default: + return "", false + } +} + func normalizeCompositeQueryValue(v interface{}) interface{} { if v == nil { return nil @@ -91,7 +123,7 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { // 部分驱动(如 Kingbase)会返回复杂结构体值,直接透传会导致前端渲染和比较开销激增。 // 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。 if tm, ok := v.(time.Time); ok { - return tm.Format(time.RFC3339Nano) + return normalizeTemporalValueForDisplay(tm, "") } if stringer, ok := v.(fmt.Stringer); ok { return stringer.String() diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index 285344e..a66faf4 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -195,3 +195,33 @@ func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) { t.Fatalf("time.Time 规整值异常,实际=%s", text) } } + +func TestNormalizeQueryValueWithDBType_ZeroTemporalValues(t *testing.T) { + zero := time.Time{} + cases := []struct { + name string + dbType string + wantText string + }{ + {name: "date", dbType: "DATE", wantText: "0000-00-00"}, + {name: "newdate", dbType: "NEWDATE", wantText: "0000-00-00"}, + {name: "datetime", dbType: "DATETIME", wantText: "0000-00-00 00:00:00"}, + {name: "timestamp", dbType: "TIMESTAMP", wantText: "0000-00-00 00:00:00"}, + {name: "time", dbType: "TIME", wantText: "00:00:00"}, + {name: "year", dbType: "YEAR", wantText: "0000"}, + {name: "unknown", dbType: "", wantText: "0000-00-00 00:00:00"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeQueryValueWithDBType(zero, tc.dbType) + text, ok := got.(string) + if !ok { + t.Fatalf("期望 string,实际=%v(%T)", got, got) + } + if text != tc.wantText { + t.Fatalf("dbType=%s 期望=%s,实际=%s", tc.dbType, tc.wantText, text) + } + }) + } +}