feat(frontend): 升级 DataGrid 组件并引入高性能拖拽交互

- 实现基于原生 DOM 事件的零渲染列宽拖拽,彻底解决卡顿与误触排序问题
- 查询编辑器集成 DataGrid,支持 SQL 结果直接编辑与事务提交
- 侧边栏新增上下文感知的 "新建查询" 快捷入口
- 优化 TabManager 渲染逻辑与全局布局,消除不必要的滚动条
This commit is contained in:
杨国锋
2026-02-02 11:32:49 +08:00
parent e0181cc7ac
commit af91c916c3
33 changed files with 2020 additions and 1618 deletions

72
internal/app/app.go Normal file
View File

@@ -0,0 +1,72 @@
package app
import (
"context"
"fmt"
"sync"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
// App struct
type App struct {
ctx context.Context
dbCache map[string]db.Database // Cache for DB connections
mu sync.Mutex // Mutex for cache access
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{
dbCache: make(map[string]db.Database),
}
}
// Startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
}
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
for _, dbInst := range a.dbCache {
dbInst.Close()
}
}
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
return fmt.Sprintf("%s|%s|%s:%d|%s|%s|%v", config.Type, config.User, config.Host, config.Port, config.Database, config.SSH.Host, config.UseSSH)
}
// Helper: Get or create a database connection
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
key := getCacheKey(config)
a.mu.Lock()
defer a.mu.Unlock()
if dbInst, ok := a.dbCache[key]; ok {
if err := dbInst.Ping(); err == nil {
return dbInst, nil
}
dbInst.Close()
delete(a.dbCache, key)
}
dbInst, err := db.NewDatabase(config.Type)
if err != nil {
return nil, err
}
if err := dbInst.Connect(config); err != nil {
return nil, err
}
a.dbCache[key] = dbInst
return dbInst, nil
}

263
internal/app/methods_db.go Normal file
View File

@@ -0,0 +1,263 @@
package app
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// Generic DB Methods
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
key := getCacheKey(config)
// Use an anonymous function to scope the lock
func() {
a.mu.Lock()
defer a.mu.Unlock()
if oldDB, ok := a.dbCache[key]; ok {
oldDB.Close()
delete(a.dbCache, key)
}
}()
// getDatabase acquires the lock internally, so we must be unlocked here
_, err := a.getDatabase(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Connected successfully"}
}
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := config
runConfig.Database = ""
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
query := fmt.Sprintf("CREATE DATABASE `%%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", dbName)
if runConfig.Type == "postgres" {
query = fmt.Sprintf("CREATE DATABASE \"%%s\"", dbName)
}
_, err = dbInst.Exec(query)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Database created successfully"}
}
func (a *App) MySQLConnect(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "mysql"
return a.DBConnect(config)
}
func (a *App) MySQLQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
config.Type = "mysql"
return a.DBQuery(config, dbName, query)
}
func (a *App) MySQLGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "mysql"
return a.DBGetDatabases(config)
}
func (a *App) MySQLGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult {
config.Type = "mysql"
return a.DBGetTables(config, dbName)
}
func (a *App) MySQLShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
config.Type = "mysql"
return a.DBShowCreateTable(config, dbName, tableName)
}
func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
lowerQuery := strings.TrimSpace(strings.ToLower(query))
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
data, columns, err := dbInst.Query(query)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: data, Fields: columns}
} else {
affected, err := dbInst.Exec(query)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}}
}
}
func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
dbInst, err := a.getDatabase(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
dbs, err := dbInst.GetDatabases()
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
var resData []map[string]string
for _, name := range dbs {
resData = append(resData, map[string]string{"Database": name})
}
return connection.QueryResult{Success: true, Data: resData}
}
func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = 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()}
}
var resData []map[string]string
for _, name := range tables {
resData = append(resData, map[string]string{"Table": name})
}
return connection.QueryResult{Success: true, Data: resData}
}
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
sqlStr, err := dbInst.GetCreateStatement(dbName, tableName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: sqlStr}
}
func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
columns, err := dbInst.GetColumns(dbName, tableName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: columns}
}
func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
indexes, err := dbInst.GetIndexes(dbName, tableName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: indexes}
}
func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
fks, err := dbInst.GetForeignKeys(dbName, tableName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: fks}
}
func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
triggers, err := dbInst.GetTriggers(dbName, tableName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: triggers}
}
func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
cols, err := dbInst.GetAllColumns(dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: cols}
}

View File

@@ -0,0 +1,291 @@
package app
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) OpenSQLFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select SQL File",
Filters: []runtime.FileFilter{
{
DisplayName: "SQL Files (*.sql)",
Pattern: "*.sql",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
content, err := os.ReadFile(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: string(content)}
}
func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: fmt.Sprintf("Import into %s", tableName),
Filters: []runtime.FileFilter{
{
DisplayName: "Data Files",
Pattern: "*.csv;*.json",
},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
f, err := os.Open(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
var rows []map[string]interface{ }
if strings.HasSuffix(strings.ToLower(selection), ".json") {
decoder := json.NewDecoder(f)
if err := decoder.Decode(&rows); err != nil {
return connection.QueryResult{Success: false, Message: "JSON Parse Error: " + err.Error()}
}
} else if strings.HasSuffix(strings.ToLower(selection), ".csv") {
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
return connection.QueryResult{Success: false, Message: "CSV Parse Error: " + err.Error()}
}
if len(records) < 2 {
return connection.QueryResult{Success: false, Message: "CSV empty or missing header"}
}
headers := records[0]
for _, record := range records[1:] {
row := make(map[string]interface{ })
for i, val := range record {
if i < len(headers) {
if val == "NULL" {
row[headers[i]] = nil
} else {
row[headers[i]] = val
}
}
}
rows = append(rows, row)
}
} else {
return connection.QueryResult{Success: false, Message: "Unsupported file format"}
}
if len(rows) == 0 {
return connection.QueryResult{Success: true, Message: "No data to import"}
}
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
successCount := 0
errCount := 0
firstRow := rows[0]
var cols []string
for k := range firstRow {
cols = append(cols, k)
}
for _, row := range rows {
var values []string
for _, col := range cols {
val := row[col]
if val == nil {
values = append(values, "NULL")
} else {
vStr := fmt.Sprintf("%v", val)
vStr = strings.ReplaceAll(vStr, "'", "''")
values = append(values, fmt.Sprintf("'%s'", vStr))
}
}
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)",
tableName,
strings.Join(cols, ", "),
strings.Join(values, ", "))
if runConfig.Type == "postgres" {
pgCols := make([]string, len(cols))
for i, c := range cols { pgCols[i] = fmt.Sprintf("\"%s\"", c) }
query = fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES (%s)",
tableName,
strings.Join(pgCols, ", "),
strings.Join(values, ", "))
}
_, err := dbInst.Exec(query)
if err != nil {
errCount++
fmt.Println("Import Error:", err)
} else {
successCount++
}
}
return connection.QueryResult{Success: true, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, errCount)}
}
func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if applier, ok := dbInst.(db.BatchApplier); ok {
err := applier.ApplyChanges(tableName, changes)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Changes applied successfully"}
}
return connection.QueryResult{Success: false, Message: "Batch updates not supported for this database type"}
}
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: fmt.Sprintf("Export %s", tableName),
DefaultFilename: fmt.Sprintf("%s.%s", tableName, format),
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
runConfig := config
if dbName != "" {
runConfig.Database = dbName
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
query := fmt.Sprintf("SELECT * FROM `%s`", tableName)
if runConfig.Type == "postgres" {
query = fmt.Sprintf("SELECT * FROM \"%s\"", tableName)
}
data, columns, err := dbInst.Query(query)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
format = strings.ToLower(format)
var csvWriter *csv.Writer
var jsonEncoder *json.Encoder
var isJsonFirstRow = true
switch format {
case "csv", "xlsx":
f.Write([]byte{0xEF, 0xBB, 0xBF})
csvWriter = csv.NewWriter(f)
defer csvWriter.Flush()
if err := csvWriter.Write(columns); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
case "json":
f.WriteString("[\n")
jsonEncoder = json.NewEncoder(f)
jsonEncoder.SetIndent(" ", " ")
case "md":
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
seps := make([]string, len(columns))
for i := range seps {
seps[i] = "---"
}
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
default:
return connection.QueryResult{Success: false, Message: "Unsupported format: " + format}
}
for _, rowMap := range data {
record := make([]string, len(columns))
for i, col := range columns {
val := rowMap[col]
if val == nil {
record[i] = "NULL"
} else {
s := fmt.Sprintf("%v", val)
if format == "md" {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", "<br>")
}
record[i] = s
}
}
switch format {
case "csv", "xlsx":
if err := csvWriter.Write(record); err != nil {
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
case "json":
if !isJsonFirstRow {
f.WriteString(",\n")
}
if err := jsonEncoder.Encode(rowMap); err != nil {
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
isJsonFirstRow = false
case "md":
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
}
}
if format == "json" {
f.WriteString("\n]")
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}