From 89e2247c0578410090e83b8230c0d9fab46023df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Tue, 3 Feb 2026 19:49:04 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(database):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=BA=93/=E8=A1=A8=E7=BA=A7=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=B8=8E=E5=A4=87=E4=BB=BD=E8=83=BD=E5=8A=9B=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库节点新增导出全部表结构/结构+数据 SQL(ExportDatabaseSQL) - 表节点支持多选/单选右键导出与备份(ExportTablesSQL) - ExportTable 支持导出 SQL(结构+数据) - 双击表仅打开表数据,不再触发展开/折叠 --- frontend/src/components/Sidebar.tsx | 114 +++++++++- frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + internal/app/methods_file.go | 320 +++++++++++++++++++++++++++- 4 files changed, 434 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d7df2bb..f11b5bf 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -47,6 +47,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [expandedKeys, setExpandedKeys] = useState([]); const [autoExpandParent, setAutoExpandParent] = useState(true); const [loadedKeys, setLoadedKeys] = useState([]); + const [selectedKeys, setSelectedKeys] = useState([]); + const [selectedNodes, setSelectedNodes] = useState([]); const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null); // Virtual Scroll State @@ -283,10 +285,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onSelect = (keys: React.Key[], info: any) => { - if (!info.node.selected) { + setSelectedKeys(keys); + setSelectedNodes(info.selectedNodes || []); + + if (keys.length === 0) { setActiveContext(null); return; } + if (!info.selected) return; const { type, dataRef, key, title } = info.node; @@ -313,15 +319,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onDoubleClick = (e: any, node: any) => { - const key = node.key; - const isExpanded = expandedKeys.includes(key); - const newExpandedKeys = isExpanded - ? expandedKeys.filter(k => k !== key) - : [...expandedKeys, key]; - - setExpandedKeys(newExpandedKeys); - if (!isExpanded) setAutoExpandParent(false); - if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; addTab({ @@ -332,6 +329,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> dbName, tableName, }); + return; } else if (node.type === 'saved-query') { const q = node.dataRef; addTab({ @@ -342,7 +340,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> dbName: q.dbName, query: q.sql }); + return; } + + const key = node.key; + const isExpanded = expandedKeys.includes(key); + const newExpandedKeys = isExpanded + ? expandedKeys.filter(k => k !== key) + : [...expandedKeys, key]; + + setExpandedKeys(newExpandedKeys); + if (!isExpanded) setAutoExpandParent(false); }; const handleCopyStructure = async (node: any) => { @@ -382,6 +390,60 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } }; + const normalizeConnConfig = (raw: any) => ({ + ...raw, + port: Number(raw.port), + password: raw.password || "", + database: raw.database || "", + useSSH: raw.useSSH || false, + ssh: raw.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }); + + const handleExportDatabaseSQL = async (node: any, includeData: boolean) => { + const conn = node.dataRef; + const dbName = conn.dbName || node.title; + const hide = message.loading(includeData ? `正在备份数据库 ${dbName} (结构+数据)...` : `正在导出数据库 ${dbName} 表结构...`, 0); + try { + const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData); + hide(); + if (res.success) { + message.success('导出成功'); + } else if (res.message !== 'Cancelled') { + message.error('导出失败: ' + res.message); + } + } catch (e: any) { + hide(); + message.error('导出失败: ' + (e?.message || String(e))); + } + }; + + const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => { + if (!nodes || nodes.length === 0) return; + const first = nodes[0].dataRef; + const dbName = first.dbName; + const connId = first.id; + const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName); + if (!allSame) { + message.error('请在同一连接、同一数据库下选择多张表进行导出'); + return; + } + + const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean); + const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0); + try { + const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData); + hide(); + if (res.success) { + message.success('导出成功'); + } else if (res.message !== 'Cancelled') { + message.error('导出失败: ' + res.message); + } + } catch (e: any) { + hide(); + message.error('导出失败: ' + (e?.message || String(e))); + } + }; + const handleRunSQLFile = async (node: any) => { const res = await (window as any).go.app.App.OpenSQLFile(); if (res.success) { @@ -550,6 +612,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> icon: , onClick: () => loadTables(node) }, + { + key: 'export-db-schema', + label: '导出全部表结构 (SQL)', + icon: , + onClick: () => handleExportDatabaseSQL(node, false) + }, + { + key: 'backup-db-sql', + label: '备份全部表 (结构+数据 SQL)', + icon: , + onClick: () => handleExportDatabaseSQL(node, true) + }, { type: 'divider' }, { key: 'disconnect-db', @@ -588,7 +662,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } ]; } else if (node.type === 'table') { + const sameContextSelectedTables = (selectedNodes || []).filter((n: any) => n?.type === 'table' && n?.dataRef?.id === node?.dataRef?.id && n?.dataRef?.dbName === node?.dataRef?.dbName); + const selectedForAction = sameContextSelectedTables.some((n: any) => n?.key === node.key) ? sameContextSelectedTables : [node]; + return [ + ...(selectedForAction.length > 1 ? ([ + { + key: 'export-selected-schema', + label: `导出选中表结构 (${selectedForAction.length}) (SQL)`, + icon: , + onClick: () => handleExportTablesSQL(selectedForAction, false) + }, + { + key: 'backup-selected-sql', + label: `备份选中表 (${selectedForAction.length}) (结构+数据 SQL)`, + icon: , + onClick: () => handleExportTablesSQL(selectedForAction, true) + }, + { type: 'divider' as const } + ]) : []), { key: 'new-query', label: '新建查询', @@ -684,6 +776,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> loadedKeys={loadedKeys} onLoad={setLoadedKeys} autoExpandParent={autoExpandParent} + multiple + selectedKeys={selectedKeys} blockNode height={treeHeight} onRightClick={onRightClick} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 15b34ad..9d91802 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -35,8 +35,12 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; +export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise; + export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array,arg4:boolean):Promise; + export function ImportConfigFile():Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 1537055..cf0859b 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -66,10 +66,18 @@ export function ExportData(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4); } +export function ExportDatabaseSQL(arg1, arg2, arg3) { + return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3); +} + export function ExportTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4); } +export function ExportTablesSQL(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4); +} + export function ImportConfigFile() { return window['go']['app']['App']['ImportConfigFile'](); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index e717fe6..7654799 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -1,11 +1,16 @@ package app import ( + "bufio" "encoding/csv" "encoding/json" "fmt" + "math" "os" + "sort" + "strconv" "strings" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" @@ -213,12 +218,36 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab } runConfig := normalizeRunConfig(config, dbName) - + dbInst, err := a.getDatabase(runConfig) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + format = strings.ToLower(format) + if format == "sql" { + f, err := os.Create(filename) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + defer f.Close() + + w := bufio.NewWriterSize(f, 1024*1024) + defer w.Flush() + + if err := writeSQLHeader(w, runConfig, dbName); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := writeSQLFooter(w, runConfig); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "Export successful"} + } + query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName)) data, columns, err := dbInst.Query(query) @@ -232,7 +261,6 @@ data, columns, err := dbInst.Query(query) } defer f.Close() - format = strings.ToLower(format) var csvWriter *csv.Writer var jsonEncoder *json.Encoder var isJsonFirstRow = true @@ -301,6 +329,127 @@ data, columns, err := dbInst.Query(query) return connection.QueryResult{Success: true, Message: "Export successful"} } +func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult { + safeDbName := strings.TrimSpace(dbName) + if safeDbName == "" { + safeDbName = "export" + } + suffix := "schema" + if includeData { + suffix = "backup" + } + defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames)) + if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" { + defaultFilename = fmt.Sprintf("%s_%s.sql", strings.TrimSpace(tableNames[0]), suffix) + } + + filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + Title: "Export Tables (SQL)", + DefaultFilename: defaultFilename, + }) + if err != nil || filename == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + + runConfig := normalizeRunConfig(config, dbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + tables := make([]string, 0, len(tableNames)) + seen := make(map[string]struct{}, len(tableNames)) + for _, t := range tableNames { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + tables = append(tables, t) + } + sort.Strings(tables) + + f, err := os.Create(filename) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + defer f.Close() + + w := bufio.NewWriterSize(f, 1024*1024) + defer w.Flush() + + if err := writeSQLHeader(w, runConfig, dbName); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + for _, t := range tables { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + } + if err := writeSQLFooter(w, runConfig); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "Export successful"} +} + +func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName string, includeData bool) connection.QueryResult { + safeDbName := strings.TrimSpace(dbName) + if safeDbName == "" { + return connection.QueryResult{Success: false, Message: "dbName required"} + } + suffix := "schema" + if includeData { + suffix = "backup" + } + + filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + Title: fmt.Sprintf("Export %s (SQL)", safeDbName), + DefaultFilename: fmt.Sprintf("%s_%s.sql", safeDbName, suffix), + }) + if err != nil || filename == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + + runConfig := normalizeRunConfig(config, dbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + tables, err := dbInst.GetTables(dbName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + sort.Strings(tables) + + f, err := os.Create(filename) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + defer f.Close() + + w := bufio.NewWriterSize(f, 1024*1024) + defer w.Flush() + + if err := writeSQLHeader(w, runConfig, dbName); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + for _, t := range tables { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + } + if err := writeSQLFooter(w, runConfig); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "Export successful"} +} + func quoteIdentByType(dbType string, ident string) string { if ident == "" { return ident @@ -340,6 +489,173 @@ func quoteQualifiedIdentByType(dbType string, ident string) string { return strings.Join(quotedParts, ".") } +func writeSQLHeader(w *bufio.Writer, config connection.ConnectionConfig, dbName string) error { + now := time.Now().Format("2006-01-02 15:04:05") + if _, err := w.WriteString(fmt.Sprintf("-- GoNavi SQL Export\n-- Time: %s\n", now)); err != nil { + return err + } + if strings.TrimSpace(dbName) != "" { + if _, err := w.WriteString(fmt.Sprintf("-- Database: %s\n\n", dbName)); err != nil { + return err + } + } + + if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" && strings.TrimSpace(dbName) != "" { + if _, err := w.WriteString(fmt.Sprintf("USE %s;\n\n", quoteIdentByType("mysql", dbName))); err != nil { + return err + } + if _, err := w.WriteString("SET FOREIGN_KEY_CHECKS=0;\n\n"); err != nil { + return err + } + } + + return nil +} + +func writeSQLFooter(w *bufio.Writer, config connection.ConnectionConfig) error { + if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" { + if _, err := w.WriteString("\nSET FOREIGN_KEY_CHECKS=1;\n"); err != nil { + return err + } + } + return nil +} + +func qualifyTable(schemaName, tableName string) string { + schemaName = strings.TrimSpace(schemaName) + tableName = strings.TrimSpace(tableName) + if schemaName == "" { + return tableName + } + return schemaName + "." + tableName +} + +func ensureSQLTerminator(sql string) string { + trimmed := strings.TrimSpace(sql) + if trimmed == "" { + return sql + } + if strings.HasSuffix(trimmed, ";") { + return sql + } + return sql + ";" +} + +func isMySQLHexLiteral(s string) bool { + if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) { + return false + } + for i := 2; i < len(s); i++ { + c := s[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func formatSQLValue(dbType string, v interface{}) string { + if v == nil { + return "NULL" + } + + switch val := v.(type) { + case bool: + if val { + return "1" + } + return "0" + case int: + return strconv.Itoa(val) + case int8, int16, int32, int64: + return fmt.Sprintf("%d", val) + case uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", val) + case float32: + f := float64(val) + if math.IsNaN(f) || math.IsInf(f, 0) { + return "NULL" + } + return strconv.FormatFloat(f, 'f', -1, 32) + case float64: + if math.IsNaN(val) || math.IsInf(val, 0) { + return "NULL" + } + return strconv.FormatFloat(val, 'f', -1, 64) + case time.Time: + return "'" + val.Format("2006-01-02 15:04:05") + "'" + case string: + if strings.ToLower(strings.TrimSpace(dbType)) == "mysql" && isMySQLHexLiteral(val) { + return val + } + escaped := strings.ReplaceAll(val, "'", "''") + return "'" + escaped + "'" + default: + escaped := strings.ReplaceAll(fmt.Sprintf("%v", v), "'", "''") + return "'" + escaped + "'" + } +} + +func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeData bool) error { + schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + + if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil { + return err + } + if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil { + return err + } + if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil { + return err + } + + createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName) + if err != nil { + return err + } + if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil { + return err + } + if _, err := w.WriteString("\n\n"); err != nil { + return err + } + + if !includeData { + return nil + } + + qualified := qualifyTable(schemaName, pureTableName) + selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified)) + data, columns, err := dbInst.Query(selectSQL) + if err != nil { + return err + } + if len(data) == 0 { + if _, err := w.WriteString("-- (0 rows)\n"); err != nil { + return err + } + return nil + } + + quotedCols := make([]string, 0, len(columns)) + for _, c := range columns { + quotedCols = append(quotedCols, quoteIdentByType(config.Type, c)) + } + quotedTable := quoteQualifiedIdentByType(config.Type, qualified) + + for _, row := range data { + values := make([]string, 0, len(columns)) + for _, c := range columns { + values = append(values, formatSQLValue(config.Type, row[c])) + } + if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil { + return err + } + } + + return nil +} + // ExportData exports provided data to a file func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult { if defaultName == "" {