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 1/3] =?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 == "" { From 94e5b8d2c60cd1a782684f039a9d863eadd50119 Mon Sep 17 00:00:00 2001 From: baicaixiaozhan Date: Tue, 3 Feb 2026 21:49:43 +0800 Subject: [PATCH 2/3] chore: add Github issues templates --- .github/ISSUE_TEMPLATE/01-bug_report.yml | 58 +++++++++++++++++++ .github/ISSUE_TEMPLATE/02-feature_request.yml | 37 ++++++++++++ .github/ISSUE_TEMPLATE/03-generic.yml | 30 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + 4 files changed, 126 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/01-bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/02-feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/03-generic.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/01-bug_report.yml b/.github/ISSUE_TEMPLATE/01-bug_report.yml new file mode 100644 index 0000000..5baaa9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug_report.yml @@ -0,0 +1,58 @@ +name: 问题反馈 +description: 软件问题反馈 +title: "[Bug] " +labels: ["bug"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: input + id: system + attributes: + label: 操作系统及版本 + placeholder: Windows 10 22H2 / macOS Mojave / Linux + validations: + required: true + + - type: input + id: version + attributes: + label: 软件安装版本 + placeholder: v0.2.3 + validations: + required: true + + - type: textarea + id: description + attributes: + label: 问题简述及复现流程 + description: 请详细描述你遇到的问题,并提供复现步骤 + placeholder: | + 1. 打开软件 + 2. 点击 xxx + 3. 预期结果是 ... + 4. 实际结果是 ... + 5. 截图 ... + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 + + - type: checkboxes + id: pr + attributes: + label: 是否愿意提交 PR 修复当前 Issue + options: + - label: 我愿意尝试提交 PR diff --git a/.github/ISSUE_TEMPLATE/02-feature_request.yml b/.github/ISSUE_TEMPLATE/02-feature_request.yml new file mode 100644 index 0000000..268211f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature_request.yml @@ -0,0 +1,37 @@ +name: 功能建议 +description: 添加全新功能或改进现有功能 +title: "[Enhancement] " +labels: ["enhancement"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: textarea + id: feature + attributes: + label: 功能描述 + description: 请详细描述你希望添加或改进的功能 + placeholder: 请描述你想要的功能 + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 + + - type: checkboxes + id: pr + attributes: + label: 是否愿意提交 PR 实现当前 Issue + options: + - label: 我愿意尝试提交 PR diff --git a/.github/ISSUE_TEMPLATE/03-generic.yml b/.github/ISSUE_TEMPLATE/03-generic.yml new file mode 100644 index 0000000..d3aeb5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-generic.yml @@ -0,0 +1,30 @@ +name: 其他反馈 +description: 其他类型反馈、建议或讨论 +title: "[Question] " +labels: ["question"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: textarea + id: content + attributes: + label: 内容 + description: 请填写你的反馈、建议或讨论内容 + placeholder: 请描述你的问题或想法 + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false From 34c494ce510a048f10b138ccf4aad93305bf32e8 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 22:44:48 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=20=E2=9A=A1=EF=B8=8F=20optimize(core):=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=A4=9A=E8=AF=AD=E5=8F=A5=E5=A4=9A=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=B8=8E=E5=A4=A7=E8=A1=A8=E4=BA=A4=E4=BA=92/?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示; - 支持选中运行;结果 Tab 支持关闭 - 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示) - 补齐 PostgreSQL/SQLite 设计表元数据接口; - 修复 Kingbase schema/标识符引用导致打开表失败 - 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有 --- frontend/src/App.tsx | 14 +- frontend/src/components/DataGrid.tsx | 67 ++-- frontend/src/components/DataViewer.tsx | 24 +- frontend/src/components/QueryEditor.tsx | 424 ++++++++++++++++++---- frontend/src/components/TabManager.tsx | 85 ++++- frontend/src/components/TableDesigner.tsx | 1 - frontend/src/store.ts | 30 +- internal/db/kingbase_impl.go | 23 +- internal/db/postgres_impl.go | 296 ++++++++++++++- internal/db/sqlite_impl.go | 326 ++++++++++++++++- 10 files changed, 1149 insertions(+), 141 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4feaf2..f5b630f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -285,12 +285,12 @@ function App() { title="拖动调整宽度" /> - -
- -
- {isLogPanelOpen && ( - +
+ +
+ {isLogPanelOpen && ( + setIsLogPanelOpen(false)} onResizeStart={handleLogResizeStart} @@ -343,4 +343,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 1a1d1c8..8601b35 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -243,26 +243,38 @@ const DataGrid: React.FC = ({ const containerRef = useRef(null); useEffect(() => { - if (!containerRef.current) return; - - let rafId: number; + const el = containerRef.current; + if (!el) return; + + let rafId: number | null = null; + const resizeObserver = new ResizeObserver(entries => { + if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { - for (let entry of entries) { - // Use boundingClientRect for more accurate render size (including padding if any) - const height = entry.contentRect.height; - if (height < 50) return; - // Subtract header (~42px) and a buffer - const h = Math.max(100, height - 42); - setTableHeight(h); - } + const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current; + if (!target) return; + + const height = target.getBoundingClientRect().height; + if (!Number.isFinite(height) || height < 50) return; + + const headerEl = + (target.querySelector('.ant-table-header') as HTMLElement | null) || + (target.querySelector('.ant-table-thead') as HTMLElement | null); + const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN; + const headerHeight = + Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; + + // 留一点余量,避免底部(边框/滚动条)遮挡最后一行 + const extraBottom = 16; + const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); + setTableHeight(nextHeight); }); }); - - resizeObserver.observe(containerRef.current); + + resizeObserver.observe(el); return () => { resizeObserver.disconnect(); - cancelAnimationFrame(rafId); + if (rafId !== null) cancelAnimationFrame(rafId); }; }, []); @@ -727,12 +739,12 @@ const DataGrid: React.FC = ({ const enableVirtual = mergedDisplayData.length >= 200; return ( -
- {/* Toolbar */} -
- {onReload &&
)} - + {/* Ghost Resize Line for Columns */}
= ({ tab }) => { ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; + const normalizeIdentPart = (ident: string) => { + let raw = (ident || '').trim(); + if (!raw) return raw; + const first = raw[0]; + const last = raw[raw.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`')) { + raw = raw.slice(1, -1).trim(); + } + // 防御:如果传入已包含引号(例如 `"schema"."table"` 的拆分结果),移除残留引号再重新安全转义。 + raw = raw.replace(/["`]/g, '').trim(); + return raw; + }; + const quoteIdentPart = (ident: string) => { - if (!ident) return ident; - if (config.type === 'mysql') return `\`${ident.replace(/`/g, '``')}\``; - return `"${ident.replace(/"/g, '""')}"`; + const raw = normalizeIdentPart(ident); + if (!raw) return raw; + if (config.type === 'mysql') return `\`${raw.replace(/`/g, '``')}\``; + return `"${raw.replace(/"/g, '""')}"`; }; const quoteQualifiedIdent = (ident: string) => { const raw = (ident || '').trim(); if (!raw) return raw; - const parts = raw.split('.').filter(Boolean); + const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean); if (parts.length <= 1) return quoteIdentPart(raw); return parts.map(quoteIdentPart).join('.'); }; @@ -227,7 +241,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter return ( -
+
= ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); - // DataGrid State - const [results, setResults] = useState([]); - const [columnNames, setColumnNames] = useState([]); - const [pkColumns, setPkColumns] = useState([]); - const [targetTableName, setTargetTableName] = useState(undefined); + type ResultSet = { + key: string; + sql: string; + rows: any[]; + columns: string[]; + tableName?: string; + pkColumns: string[]; + readOnly: boolean; + }; + + // Result Sets + const [resultSets, setResultSets] = useState([]); + const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -210,6 +218,144 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }, ]; + const splitSQLStatements = (sql: string): string[] => { + const text = (sql || '').replace(/\r\n/g, '\n'); + const statements: string[] = []; + + let cur = ''; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$ + + const push = () => { + const s = cur.trim(); + if (s) statements.push(s); + cur = ''; + }; + + const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + const prev = i > 0 ? text[i - 1] : ''; + const next2 = i + 2 < text.length ? text[i + 2] : ''; + + if (!inSingle && !inDouble && !inBacktick) { + if (inLineComment) { + cur += ch; + if (ch === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + cur += ch; + if (ch === '*' && next === '/') { + cur += next; + i++; + inBlockComment = false; + } + continue; + } + + // Start comments + if (ch === '/' && next === '*') { + cur += ch + next; + i++; + inBlockComment = true; + continue; + } + if (ch === '#') { + cur += ch; + inLineComment = true; + continue; + } + if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { + cur += ch + next; + i++; + inLineComment = true; + continue; + } + + // Dollar-quoted strings (PG/Kingbase) + if (dollarTag) { + if (text.startsWith(dollarTag, i)) { + cur += dollarTag; + i += dollarTag.length - 1; + dollarTag = null; + } else { + cur += ch; + } + continue; + } + if (ch === '$') { + const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); + if (m && m[0]) { + dollarTag = m[0]; + cur += dollarTag; + i += dollarTag.length - 1; + continue; + } + } + } + + if (escaped) { + cur += ch; + escaped = false; + continue; + } + + if ((inSingle || inDouble) && ch === '\\') { + cur += ch; + escaped = true; + continue; + } + + if (!inDouble && !inBacktick && ch === '\'') { + inSingle = !inSingle; + cur += ch; + continue; + } + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + cur += ch; + continue; + } + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + cur += ch; + continue; + } + + if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === ';')) { + push(); + continue; + } + + cur += ch; + } + + push(); + return statements; + }; + + const getSelectedSQL = (): string => { + const editor = editorRef.current; + if (!editor) return ''; + const model = editor.getModel?.(); + const selection = editor.getSelection?.(); + if (!model || !selection) return ''; + + const selected = model.getValueInRange?.(selection) || ''; + if (typeof selected !== 'string') return ''; + if (!selected.trim()) return ''; + return selected; + }; + const handleRun = async () => { if (!query.trim()) return; if (!currentDb) { @@ -217,6 +363,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return; } setLoading(true); + const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error("Connection not found"); @@ -233,76 +380,114 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; - // Detect Simple Table Query - let simpleTableName: string | undefined = undefined; - let primaryKeys: string[] = []; - - // Naive regex to detect SELECT * FROM table - const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); - if (tableMatch) { - simpleTableName = tableMatch[1]; - // Fetch PKs for editing - const resCols = await DBGetColumns(config as any, currentDb, simpleTableName); - if (resCols.success) { - primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); - } - } - setTargetTableName(simpleTableName); - setPkColumns(primaryKeys); - - const startTime = Date.now(); try { - const res = await DBQuery(config as any, currentDb, query); - const duration = Date.now() - startTime; - - addSqlLog({ - id: `log-${Date.now()}-query`, - timestamp: Date.now(), - sql: query, - status: res.success ? 'success' : 'error', - duration, - message: res.success ? '' : res.message, - affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), - dbName: currentDb - }); + const rawSQL = getSelectedSQL() || query; + const statements = splitSQLStatements(rawSQL); + if (statements.length === 0) { + message.info('没有可执行的 SQL。'); + setResultSets([]); + setActiveResultKey(''); + return; + } + + const nextResultSets: ResultSet[] = []; + + for (let idx = 0; idx < statements.length; idx++) { + const sql = statements[idx]; + const startTime = Date.now(); + const res = await DBQuery(config as any, currentDb, sql); + const duration = Date.now() - startTime; + + addSqlLog({ + id: `log-${Date.now()}-query-${idx + 1}`, + timestamp: Date.now(), + sql, + status: res.success ? 'success' : 'error', + duration, + message: res.success ? '' : res.message, + affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), + dbName: currentDb + }); + + if (!res.success) { + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; + message.error(prefix + res.message); + setResultSets([]); + setActiveResultKey(''); + return; + } + + if (Array.isArray(res.data)) { + const rows = (res.data as any[]) || []; + const cols = (res.fields && res.fields.length > 0) + ? (res.fields as string[]) + : (rows.length > 0 ? Object.keys(rows[0]) : []); - if (res.success) { - if (Array.isArray(res.data)) { - if (res.data.length > 0) { - const cols = Object.keys(res.data[0]); - setColumnNames(cols); - const rows = res.data as any[]; rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); - setResults(rows); + + let simpleTableName: string | undefined = undefined; + let primaryKeys: string[] = []; + const tableMatch = sql.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); + if (tableMatch) { + simpleTableName = tableMatch[1]; + const resCols = await DBGetColumns(config as any, currentDb, simpleTableName); + if (resCols.success) { + primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); + } + } + + nextResultSets.push({ + key: `result-${idx + 1}`, + sql, + rows, + columns: cols, + tableName: simpleTableName, + pkColumns: primaryKeys, + readOnly: !simpleTableName + }); } else { - message.info('查询执行成功,但没有返回结果。'); - setResults([]); - setColumnNames([]); + const affected = Number((res.data as any)?.affectedRows); + if (Number.isFinite(affected)) { + const row = { affectedRows: affected }; + (row as any)[GONAVI_ROW_KEY] = 0; + nextResultSets.push({ + key: `result-${idx + 1}`, + sql, + rows: [row], + columns: ['affectedRows'], + pkColumns: [], + readOnly: true + }); + } } - } else { - const affected = (res.data as any).affectedRows; - message.success(`受影响行数: ${affected}`); - setResults([]); - setColumnNames([]); - } - } else { - message.error(res.message); + } + + setResultSets(nextResultSets); + setActiveResultKey(nextResultSets[0]?.key || ''); + + if (statements.length > 1) { + message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); + } else if (nextResultSets.length === 0) { + message.success('执行成功。'); } } catch (e: any) { message.error("Error executing query: " + e.message); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), - sql: query, + sql: getSelectedSQL() || query, status: 'error', - duration: Date.now() - startTime, + duration: Date.now() - runStartTime, message: e.message, dbName: currentDb }); + setResultSets([]); + setActiveResultKey(''); + } finally { + setLoading(false); } - setLoading(false); }; const handleSave = async () => { @@ -322,8 +507,66 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } }; + const handleCloseResult = (key: string) => { + setResultSets(prev => { + const idx = prev.findIndex(r => r.key === key); + if (idx < 0) return prev; + const next = prev.filter(r => r.key !== key); + + setActiveResultKey(prevActive => { + if (prevActive && prevActive !== key) return prevActive; + const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; + return nextKey; + }); + + return next; + }); + }; + return ( -
+
+