diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee25303..1180a62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,8 @@ jobs: GOOS="${TARGET_PLATFORM%%/*}" GOARCH="${TARGET_PLATFORM##*/}" DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine) + OUTDIR="drivers/${{ matrix.os_name }}" + mkdir -p "$OUTDIR" for DRIVER in "${DRIVERS[@]}"; do TAG="gonavi_${DRIVER}_driver" @@ -111,20 +113,21 @@ jobs: if [ "$GOOS" = "windows" ]; then OUTPUT="${OUTPUT}.exe" fi - echo "🔧 构建 ${OUTPUT} (tag=${TAG})" + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})" if [ "$DRIVER" = "duckdb" ]; then set +e CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${TAG}" \ -trimpath \ -ldflags "-s -w" \ - -o "${OUTPUT}" \ + -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent DUCKDB_RC=$? set -e if [ "${DUCKDB_RC}" -ne 0 ]; then echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布" - rm -f "${OUTPUT}" + rm -f "${OUTPUT_PATH}" continue fi else @@ -132,7 +135,7 @@ jobs: -tags "${TAG}" \ -trimpath \ -ldflags "-s -w" \ - -o "${OUTPUT}" \ + -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi done @@ -295,7 +298,7 @@ jobs: GoNavi-*.zip GoNavi-*.tar.gz GoNavi-*.AppImage - *-driver-agent-* + drivers/** retention-days: 1 # Phase 2: Collect all artifacts and Publish Release (Single Job) @@ -314,6 +317,59 @@ jobs: - name: List Assets run: ls -R release-assets + - name: Package Driver Agents Bundle + shell: bash + run: | + set -euo pipefail + cd release-assets + if [ ! -d drivers ]; then + echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包" + exit 0 + fi + if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then + echo "⚠️ drivers 目录为空,跳过驱动总包打包" + rm -rf drivers + exit 0 + fi + + echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" + python3 - <<'PY' + import json + import os + import zipfile + from pathlib import Path + + out_name = "GoNavi-DriverAgents.zip" + index_name = "GoNavi-DriverAgents-Index.json" + base = Path("drivers") + out_path = Path(out_name) + index_path = Path(index_name) + if out_path.exists(): + out_path.unlink() + if index_path.exists(): + index_path.unlink() + + size_index = {} + with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for p in base.rglob("*"): + if not p.is_file(): + continue + arcname = p.relative_to(base).as_posix() + zf.write(p, arcname) + size_index[p.name] = p.stat().st_size + + index_path.write_text( + json.dumps({"assets": size_index}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(f"created {out_name} size={out_path.stat().st_size} bytes") + print(f"created {index_name} entries={len(size_index)}") + PY + + # Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面 + rm -rf drivers + - name: Generate SHA256SUMS shell: bash run: | diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index c25772f..f1b4112 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -27,6 +27,7 @@ const getDefaultPortByType = (type: string) => { case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; + case 'sqlite': return 0; case 'duckdb': return 0; default: return 3306; } @@ -236,6 +237,23 @@ const ConnectionModal: React.FC<{ } }; + const normalizeFileDbPath = (rawPath: string): string => { + let pathText = String(rawPath || '').trim(); + if (!pathText) { + return ''; + } + // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 + if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { + pathText = pathText.slice(1); + } + // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 + const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); + if (legacyMatch?.[1]) { + return legacyMatch[1]; + } + return pathText; + }; + const parseMultiHostUri = (uriText: string, expectedScheme: string) => { const prefix = `${expectedScheme}://`; if (!uriText.toLowerCase().startsWith(prefix)) { @@ -335,30 +353,6 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const tryExtractPath = (uri: string, scheme: string): string | null => { - const parsed = parseMultiHostUri(uri, scheme); - if (!parsed) { - return null; - } - const host = String(parsed.hosts?.[0] || '').trim(); - const dbPath = String(parsed.database || '').trim(); - if (host && dbPath) { - return `/${host}/${dbPath}`.replace(/\/+/g, '/'); - } - if (host) { - return `/${host}`.replace(/\/+/g, '/'); - } - if (dbPath) { - return dbPath.startsWith('/') ? dbPath : `/${dbPath}`; - } - return null; - }; - - const pathFromScheme = tryExtractPath(trimmedUri, type); - if (pathFromScheme) { - return { host: decodeURIComponent(pathFromScheme) }; - } - const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') @@ -366,7 +360,7 @@ const ConnectionModal: React.FC<{ if (!rawPath) { return null; } - return { host: decodeURIComponent(rawPath) }; + return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'mongodb') { @@ -481,12 +475,11 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const pathText = String(values.host || '').trim(); + const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } - const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`; - return `${type}://${encodeURI(normalizedPath)}`; + return `${type}://${encodeURI(pathText)}`; } if (type === 'mongodb') { @@ -602,13 +595,20 @@ const ConnectionModal: React.FC<{ const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); const defaultPort = getDefaultPortByType(configType); - const normalizedHosts = normalizeAddressList(config.hosts, defaultPort); - const primaryAddress = parseHostPort( - normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = primaryAddress?.host || String(config.host || 'localhost'); - const primaryPort = primaryAddress?.port || Number(config.port || defaultPort); + const isFileDbConfigType = isFileDatabaseType(configType); + const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); + const primaryAddress = isFileDbConfigType + ? null + : parseHostPort( + normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), + defaultPort + ); + const primaryHost = isFileDbConfigType + ? normalizeFileDbPath(String(config.host || '')) + : (primaryAddress?.host || String(config.host || 'localhost')); + const primaryPort = isFileDbConfigType + ? 0 + : (primaryAddress?.port || Number(config.port || defaultPort)); const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; @@ -847,12 +847,22 @@ const ConnectionModal: React.FC<{ const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); - const parsedPrimary = parseHostPort( - toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = parsedPrimary?.host || 'localhost'; - const primaryPort = parsedPrimary?.port || defaultPort; + const isFileDbType = isFileDatabaseType(type); + + let primaryHost = 'localhost'; + let primaryPort = defaultPort; + if (isFileDbType) { + // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 + primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); + primaryPort = 0; + } else { + const parsedPrimary = parseHostPort( + toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), + defaultPort + ); + primaryHost = parsedPrimary?.host || 'localhost'; + primaryPort = parsedPrimary?.port || defaultPort; + } let hosts: string[] = []; let topology: 'single' | 'replica' | undefined; @@ -960,7 +970,36 @@ const ConnectionModal: React.FC<{ form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); - if (!isFileDatabaseType(type) && type !== 'custom') { + if (isFileDatabaseType(type)) { + setUseSSH(false); + form.setFieldsValue({ + host: '', + port: 0, + user: '', + password: '', + database: '', + useSSH: false, + sshHost: '', + sshPort: 22, + sshUser: '', + sshPassword: '', + sshKeyPath: '', + mysqlTopology: 'single', + mongoTopology: 'single', + mongoSrv: false, + mongoReadPreference: 'primary', + mongoReplicaSet: '', + mongoAuthSource: '', + mongoAuthMechanism: '', + savePassword: true, + mysqlReplicaHosts: [], + mongoHosts: [], + mysqlReplicaUser: '', + mysqlReplicaPassword: '', + mongoReplicaUser: '', + mongoReplicaPassword: '', + }); + } else if (type !== 'custom') { form.setFieldsValue({ port: defaultPort, mysqlTopology: 'single', diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 47604c9..04fde1b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd'; import type { SortOrder } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; -import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App'; +import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; +import type { ColumnDefinition } from '../types'; import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; @@ -292,6 +293,7 @@ const DataContext = React.createContext<{ handleExportSelected: (format: string, r: any) => void; copyToClipboard: (t: string) => void; tableName?: string; + enableRowContextMenu: boolean; } | null>(null); interface Item { @@ -434,7 +436,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { if (!record || !context) return