Private
Public Access
1
0

新增:连接管理、数据查询等功能

This commit is contained in:
2026-01-22 18:34:59 +08:00
parent 95d3a20292
commit 652f5e5d60
87 changed files with 15082 additions and 162 deletions

161
app.go
View File

@@ -2,16 +2,24 @@ package main
import ( import (
"context" "context"
"fmt"
"go-desk/internal/api"
"go-desk/internal/database" "go-desk/internal/database"
"go-desk/internal/filesystem" "go-desk/internal/filesystem"
"go-desk/internal/storage"
"go-desk/internal/system" "go-desk/internal/system"
"os" "os"
"github.com/wailsapp/wails/v2/pkg/runtime"
) )
// App 应用结构体 // App 应用结构体
type App struct { type App struct {
ctx context.Context ctx context.Context
db *database.DB db *database.DB
connectionAPI *api.ConnectionAPI
sqlAPI *api.SqlAPI
tabAPI *api.TabAPI
} }
// NewApp 创建新的应用实例 // NewApp 创建新的应用实例
@@ -23,13 +31,27 @@ func NewApp() *App {
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
// 初始化数据库连接 // 初始化 SQLite 本地存储(核心依赖,必须成功)
db, err := database.Init() // 如果失败,应用无法正常工作,应该 panic
_, err := storage.Init()
if err != nil { if err != nil {
println("数据库连接失败:", err.Error()) panic(fmt.Sprintf("SQLite 初始化失败,应用无法启动: %v", err))
return }
// 初始化数据库连接(可选,用于测试功能)
// 失败不影响核心功能,只记录日志
appDB, err := database.Init()
if err != nil {
println("数据库连接失败(可选功能):", err.Error())
} else {
a.db = appDB
}
// 初始化 API 层(依赖 storage
// 如果失败,应用无法正常工作,应该 panic
if err := a.initAPIs(); err != nil {
panic(fmt.Sprintf("API 初始化失败,应用无法启动: %v", err))
} }
a.db = db
} }
// QueryUsers 查询用户列表 // QueryUsers 查询用户列表
@@ -120,3 +142,128 @@ func splitEnv(env string) []string {
} }
return []string{env} return []string{env}
} }
// ========== 数据库连接管理接口 ==========
// initAPIs 初始化所有API在startup中调用
func (a *App) initAPIs() error {
var err error
a.connectionAPI, err = api.NewConnectionAPI()
if err != nil {
return err
}
a.sqlAPI, err = api.NewSqlAPI()
if err != nil {
return err
}
a.tabAPI, err = api.NewTabAPI()
return err
}
// SaveDbConnection 保存数据库连接配置
func (a *App) SaveDbConnection(req api.SaveConnectionRequest) error {
return a.connectionAPI.SaveDbConnection(req)
}
// ListDbConnections 获取连接列表
func (a *App) ListDbConnections() ([]map[string]interface{}, error) {
return a.connectionAPI.ListDbConnections()
}
// DeleteDbConnection 删除连接配置
func (a *App) DeleteDbConnection(id uint) error {
return a.connectionAPI.DeleteDbConnection(id)
}
// TestDbConnection 测试连接通过已保存的连接ID
func (a *App) TestDbConnection(id uint) error {
return a.connectionAPI.TestDbConnection(id)
}
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
return a.connectionAPI.TestDbConnectionWithParams(req)
}
// ExecuteSQL 执行 SQL 语句
// 注意SQL 语句应该已经包含分页信息LIMIT 和 OFFSET由客户端添加
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
return a.sqlAPI.ExecuteSQL(connectionId, sqlStr, database)
}
// GetDatabases 获取数据库列表
func (a *App) GetDatabases(connectionId uint) ([]string, error) {
return a.sqlAPI.GetDatabases(connectionId)
}
// GetTables 获取表列表
func (a *App) GetTables(connectionId uint, database string) ([]string, error) {
return a.sqlAPI.GetTables(connectionId, database)
}
// GetTableStructure 获取表结构
func (a *App) GetTableStructure(connectionId uint, database, tableName string) (map[string]interface{}, error) {
return a.sqlAPI.GetTableStructure(connectionId, database, tableName)
}
// GetIndexes 获取索引列表
func (a *App) GetIndexes(connectionId uint, database, tableName string) ([]map[string]interface{}, error) {
return a.sqlAPI.GetIndexes(connectionId, database, tableName)
}
// PreviewTableStructure 预览表结构变更
func (a *App) PreviewTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
return a.sqlAPI.PreviewTableStructure(connectionId, database, tableName, structure)
}
// UpdateTableStructure 更新表结构
func (a *App) UpdateTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
return a.sqlAPI.UpdateTableStructure(connectionId, database, tableName, structure)
}
// SaveResult 手动保存执行结果
func (a *App) SaveResult(connectionId uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
return a.sqlAPI.SaveResult(connectionId, database, sql, resultType, data, columns, rowsAffected, executionTime)
}
// GetResultHistory 获取结果历史
func (a *App) GetResultHistory(connectionId *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
return a.sqlAPI.GetResultHistory(connectionId, keyword, limit, offset)
}
// GetResultHistoryByID 根据ID获取结果历史
func (a *App) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
return a.sqlAPI.GetResultHistoryByID(id)
}
// DeleteResultHistory 删除结果历史
func (a *App) DeleteResultHistory(id uint) error {
return a.sqlAPI.DeleteResultHistory(id)
}
// Reload 重新加载窗口(用于菜单项)
func (a *App) Reload() {
if a.ctx != nil {
runtime.WindowReload(a.ctx)
}
}
// ClearCache 清理本地缓存(用于菜单项)
func (a *App) ClearCache() {
if a.ctx != nil {
// 发送事件到前端,让前端清理 localStorage
runtime.EventsEmit(a.ctx, "clear-cache")
}
}
// ========== SQL 标签页管理接口 ==========
// SaveSqlTabs 保存 SQL 标签页列表
func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
return a.tabAPI.SaveSqlTabs(tabs)
}
// ListSqlTabs 获取 SQL 标签页列表
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
return a.tabAPI.ListSqlTabs()
}

20
go.mod
View File

@@ -3,9 +3,12 @@ module go-desk
go 1.25.4 go 1.25.4
require ( require (
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
github.com/redis/go-redis/v9 v9.17.2
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
go.mongodb.org/mongo-driver v1.17.6
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.0 gorm.io/gorm v1.31.0
) )
@@ -13,13 +16,19 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
@@ -29,9 +38,11 @@ require (
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect github.com/samber/lo v1.49.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
@@ -42,9 +53,18 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )

68
go.sum
View File

@@ -2,8 +2,22 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -11,9 +25,13 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -24,6 +42,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -48,6 +68,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -56,6 +78,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -85,18 +112,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -105,10 +157,18 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -116,3 +176,11 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

View File

@@ -0,0 +1,109 @@
package api
import (
"go-desk/internal/service"
"go-desk/internal/storage/models"
)
// ConnectionAPI 连接管理API
type ConnectionAPI struct {
connService *service.ConnectionService
}
// NewConnectionAPI 创建连接管理API
func NewConnectionAPI() (*ConnectionAPI, error) {
connService, err := service.NewConnectionService()
if err != nil {
return nil, err
}
return &ConnectionAPI{connService}, nil
}
// SaveConnectionRequest 保存连接请求结构体
type SaveConnectionRequest struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
Options string `json:"options"`
}
// SaveDbConnection 保存数据库连接配置
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
conn := &models.DbConnection{
ID: req.ID,
Name: req.Name,
Type: req.Type,
Host: req.Host,
Port: req.Port,
Username: req.Username,
Password: req.Password,
Database: req.Database,
Options: req.Options,
}
return api.connService.SaveConnection(conn)
}
// ListDbConnections 获取连接列表
func (api *ConnectionAPI) ListDbConnections() ([]map[string]interface{}, error) {
connections, err := api.connService.ListConnections()
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(connections))
timeFormat := "2006-01-02 15:04:05"
for i, conn := range connections {
result[i] = map[string]interface{}{
"id": conn.ID,
"name": conn.Name,
"type": conn.Type,
"host": conn.Host,
"port": conn.Port,
"username": conn.Username,
"database": conn.Database,
"options": conn.Options,
"created_at": conn.CreatedAt.Format(timeFormat),
"updated_at": conn.UpdatedAt.Format(timeFormat),
}
}
return result, nil
}
func (api *ConnectionAPI) DeleteDbConnection(id uint) error {
return api.connService.DeleteConnection(id)
}
func (api *ConnectionAPI) TestDbConnection(id uint) error {
return api.connService.TestConnection(id)
}
// TestConnectionRequest 测试连接请求结构体(不保存数据)
type TestConnectionRequest struct {
ID uint `json:"id"` // 编辑模式下的连接ID用于获取已保存的密码
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
Options string `json:"options"`
}
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest) error {
return api.connService.TestConnectionWithParams(
req.Type,
req.Host,
req.Port,
req.Username,
req.Password,
req.Database,
req.Options,
req.ID,
)
}

137
internal/api/sql_api.go Normal file
View File

@@ -0,0 +1,137 @@
package api
import (
"encoding/json"
"go-desk/internal/service"
"go-desk/internal/storage/models"
"go-desk/internal/storage/repository"
)
type SqlAPI struct {
sqlService *service.SqlExecService
resultRepo repository.ResultRepository
}
func NewSqlAPI() (*SqlAPI, error) {
sqlService, err := service.NewSqlExecService()
if err != nil {
return nil, err
}
resultRepo, err := repository.NewResultRepository()
if err != nil {
return nil, err
}
return &SqlAPI{sqlService, resultRepo}, nil
}
// ExecuteSQL 执行SQL语句
// 注意SQL 语句应该已经包含分页信息LIMIT 和 OFFSET由客户端添加
func (api *SqlAPI) ExecuteSQL(connectionID uint, sqlStr string, database string) (map[string]interface{}, error) {
result, err := api.sqlService.ExecuteSQL(connectionID, sqlStr, database)
if err != nil {
return nil, err
}
response := map[string]interface{}{
"type": result.Type,
"data": result.Data,
"rowsAffected": result.RowsAffected,
"executionTime": result.ExecutionTime,
}
// 如果是查询,添加列顺序信息
if result.Type == "query" && len(result.Columns) > 0 {
response["columns"] = result.Columns
}
// 自动保存结果到历史记录(异步执行)
go func() {
api.resultRepo.Save(connectionID, database, sqlStr, result.Type, result.Data, result.Columns, result.RowsAffected, result.ExecutionTime)
}()
return response, nil
}
func (api *SqlAPI) GetDatabases(connectionID uint) ([]string, error) {
return api.sqlService.GetDatabases(connectionID)
}
func (api *SqlAPI) GetTables(connectionID uint, database string) ([]string, error) {
return api.sqlService.GetTables(connectionID, database)
}
func (api *SqlAPI) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
return api.sqlService.GetTableStructure(connectionID, database, tableName)
}
func (api *SqlAPI) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
return api.sqlService.GetIndexes(connectionID, database, tableName)
}
func (api *SqlAPI) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
return api.sqlService.PreviewTableStructure(connectionID, database, tableName, structure)
}
func (api *SqlAPI) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
return api.sqlService.UpdateTableStructure(connectionID, database, tableName, structure)
}
func (api *SqlAPI) SaveResult(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
history, err := api.resultRepo.Save(connectionID, database, sql, resultType, data, columns, rowsAffected, executionTime)
if err != nil {
return nil, err
}
return historyToMap(history), nil
}
func (api *SqlAPI) GetResultHistory(connectionID *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
histories, total, err := api.resultRepo.Search(connectionID, keyword, limit, offset)
if err != nil {
return nil, err
}
items := make([]map[string]interface{}, len(histories))
for i, h := range histories {
items[i] = historyToMap(&h)
}
return map[string]interface{}{"items": items, "total": total}, nil
}
func (api *SqlAPI) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
history, err := api.resultRepo.FindByID(id)
if err != nil || history == nil {
return nil, err
}
return historyToMap(history), nil
}
func (api *SqlAPI) DeleteResultHistory(id uint) error {
return api.resultRepo.Delete(id)
}
func historyToMap(history *models.SqlResultHistory) map[string]interface{} {
result := map[string]interface{}{
"id": history.ID,
"connection_id": history.ConnectionID,
"database": history.Database,
"sql": history.Sql,
"type": history.Type,
"rows_affected": history.RowsAffected,
"execution_time": history.ExecutionTime,
"created_at": history.CreatedAt,
}
if history.Data != "" {
var data interface{}
json.Unmarshal([]byte(history.Data), &data)
result["data"] = data
}
if history.Columns != "" {
var columns []string
json.Unmarshal([]byte(history.Columns), &columns)
result["columns"] = columns
}
return result
}

79
internal/api/tab_api.go Normal file
View File

@@ -0,0 +1,79 @@
package api
import (
"fmt"
"go-desk/internal/service"
"go-desk/internal/storage/models"
)
// TabAPI 标签页API
type TabAPI struct {
tabService *service.TabService
}
// NewTabAPI 创建标签页API
func NewTabAPI() (*TabAPI, error) {
tabService, err := service.NewTabService()
if err != nil {
return nil, err
}
return &TabAPI{tabService: tabService}, nil
}
// SaveSqlTabs 保存SQL标签页列表接收 map 格式,转换为模型)
func (api *TabAPI) SaveSqlTabs(tabs []map[string]interface{}) error {
sqlTabs := make([]models.SqlTab, len(tabs))
for idx, tabData := range tabs {
tab := models.SqlTab{
Order: idx,
}
// 处理 ID
if id, ok := tabData["id"].(float64); ok && id > 0 {
tab.ID = uint(id)
}
// 处理标题
if title, ok := tabData["title"].(string); ok {
tab.Title = title
} else {
tab.Title = fmt.Sprintf("查询 %d", idx+1)
}
// 处理内容
if content, ok := tabData["content"].(string); ok {
tab.Content = content
}
// 处理连接ID
if connId, ok := tabData["connectionId"].(float64); ok && connId > 0 {
connID := uint(connId)
tab.ConnectionID = &connID
}
sqlTabs[idx] = tab
}
return api.tabService.SaveTabs(sqlTabs)
}
// ListSqlTabs 获取SQL标签页列表返回 map 格式)
func (api *TabAPI) ListSqlTabs() ([]map[string]interface{}, error) {
tabs, err := api.tabService.ListTabs()
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(tabs))
for i, tab := range tabs {
result[i] = map[string]interface{}{
"id": tab.ID,
"title": tab.Title,
"content": tab.Content,
"connectionId": tab.ConnectionID,
"order": tab.Order,
"createdAt": tab.CreatedAt.Format("2006-01-02 15:04:05"),
"updatedAt": tab.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}
return result, nil
}

99
internal/crypto/aes.go Normal file
View File

@@ -0,0 +1,99 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
var (
// 默认密钥(实际应用中应该从配置文件或环境变量读取)
// AES-256 需要 32 字节密钥
// "go-desk-db-cli-key-32bytes123456" = 32 bytes
defaultKey = []byte("go-desk-db-cli-key-32bytes123456") // 32 bytes for AES-256
)
func init() {
// 验证密钥长度
if len(defaultKey) != 32 {
panic(fmt.Sprintf("AES-256 密钥长度必须为 32 字节,当前为 %d 字节", len(defaultKey)))
}
}
// EncryptPassword 加密密码
func EncryptPassword(password string) (string, error) {
if password == "" {
return "", nil
}
block, err := aes.NewCipher(defaultKey)
if err != nil {
return "", fmt.Errorf("创建加密器失败: %v", err)
}
// 使用 GCM 模式
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("创建 GCM 失败: %v", err)
}
// 生成随机 nonce
nonce := make([]byte, aesGCM.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("生成 nonce 失败: %v", err)
}
// 加密
ciphertext := aesGCM.Seal(nonce, nonce, []byte(password), nil)
// Base64 编码
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// DecryptPassword 解密密码
func DecryptPassword(encryptedPassword string) (string, error) {
if encryptedPassword == "" {
return "", nil
}
// 如果加密字符串为空或格式不正确,返回空字符串
if len(encryptedPassword) < 10 {
return "", nil
}
// Base64 解码
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
if err != nil {
return "", fmt.Errorf("解码失败: %v", err)
}
block, err := aes.NewCipher(defaultKey)
if err != nil {
return "", fmt.Errorf("创建解密器失败: %v", err)
}
// 使用 GCM 模式
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("创建 GCM 失败: %v", err)
}
// 提取 nonce
nonceSize := aesGCM.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("密文长度不足")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// 解密
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("解密失败: %v", err)
}
return string(plaintext), nil
}

818
internal/dbclient/mongo.go Normal file
View File

@@ -0,0 +1,818 @@
package dbclient
import (
"context"
"fmt"
"net/url"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// MongoClient MongoDB 客户端
type MongoClient struct {
client *mongo.Client
database *mongo.Database
config *MongoConfig
}
// MongoConfig MongoDB 配置
type MongoConfig struct {
Host string
Port int
Username string
Password string
Database string
AuthSource string // 认证数据库,默认为 "admin"
AuthMechanism string // 认证机制,如 "SCRAM-SHA-1", "SCRAM-SHA-256" 等
}
// NewMongoClient 创建 MongoDB 客户端
func NewMongoClient(config *MongoConfig) (*MongoClient, error) {
// 确定认证数据库,默认为 admin
authSource := config.AuthSource
if authSource == "" {
authSource = "admin"
}
// 如果指定了认证机制,直接使用;否则尝试自动检测
authMechanisms := []string{}
if config.AuthMechanism != "" {
// 用户明确指定了认证机制,只使用该机制
authMechanisms = []string{config.AuthMechanism}
} else {
// 未指定时,先尝试 SCRAM-SHA-256更安全失败则尝试 SCRAM-SHA-1
authMechanisms = []string{"SCRAM-SHA-256", "SCRAM-SHA-1"}
}
var lastErr error
for _, authMechanism := range authMechanisms {
client, err := tryConnectMongo(config, authSource, authMechanism)
if err == nil {
return client, nil
}
lastErr = err
// 如果明确指定了认证机制,失败后不再尝试其他机制
if config.AuthMechanism != "" {
break
}
}
// 所有认证机制都失败
if lastErr != nil {
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", lastErr)
}
return nil, fmt.Errorf("MongoDB 连接失败: 未知错误")
}
// tryConnectMongo 尝试使用指定的认证机制连接 MongoDB
func tryConnectMongo(config *MongoConfig, authSource, authMechanism string) (*MongoClient, error) {
// 构建连接 URI
var uri string
if config.Username != "" && config.Password != "" {
// 使用 url.UserPassword 正确转义用户名和密码中的特殊字符
// 这会正确处理 @、:、/ 等特殊字符
userInfo := url.UserPassword(config.Username, config.Password)
// 构建基础 URI
uri = fmt.Sprintf("mongodb://%s@%s:%d", userInfo.String(), config.Host, config.Port)
// 添加数据库和认证源参数
params := url.Values{}
params.Set("authSource", authSource)
// 添加认证机制参数
if authMechanism != "" {
params.Set("authMechanism", authMechanism)
}
// 如果有业务数据库,添加到路径中
if config.Database != "" {
uri = fmt.Sprintf("%s/%s?%s", uri, config.Database, params.Encode())
} else {
// MongoDB URI 要求查询参数前必须有 /,即使没有数据库名
uri = fmt.Sprintf("%s/?%s", uri, params.Encode())
}
} else if config.Database != "" {
// 没有认证信息时,数据库部分用于指定默认数据库
uri = fmt.Sprintf("mongodb://%s:%d/%s", config.Host, config.Port, config.Database)
} else {
uri = fmt.Sprintf("mongodb://%s:%d", config.Host, config.Port)
}
// 客户端选项
clientOptions := options.Client().
ApplyURI(uri).
SetConnectTimeout(5 * time.Second).
SetServerSelectionTimeout(5 * time.Second)
// 创建客户端
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
return nil, fmt.Errorf("连接 MongoDB 失败: %v", err)
}
// 测试连接
if err := client.Ping(ctx, nil); err != nil {
client.Disconnect(ctx)
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", err)
}
var database *mongo.Database
if config.Database != "" {
database = client.Database(config.Database)
}
return &MongoClient{
client: client,
database: database,
config: config,
}, nil
}
// TestMongoConnection 测试连接
func TestMongoConnection(host string, port int, username, password, database string) error {
return TestMongoConnectionWithAuthSource(host, port, username, password, database, "")
}
// TestMongoConnectionWithAuthSource 测试连接(支持指定认证数据库)
func TestMongoConnectionWithAuthSource(host string, port int, username, password, database, authSource string) error {
return TestMongoConnectionWithOptions(host, port, username, password, database, authSource, "")
}
// TestMongoConnectionWithOptions 测试连接(支持指定认证数据库和认证机制)
func TestMongoConnectionWithOptions(host string, port int, username, password, database, authSource, authMechanism string) error {
config := &MongoConfig{
Host: host,
Port: port,
Username: username,
Password: password,
Database: database,
AuthSource: authSource,
AuthMechanism: authMechanism,
}
client, err := NewMongoClient(config)
if err != nil {
return err
}
defer client.Close()
return nil
}
// Close 关闭连接
func (c *MongoClient) Close() error {
if c.client != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.client.Disconnect(ctx)
}
return nil
}
// ListDatabases 获取数据库列表
func (c *MongoClient) ListDatabases(ctx context.Context) ([]string, error) {
databases, err := c.client.ListDatabaseNames(ctx, bson.M{})
return databases, err
}
// ListCollections 获取集合列表
func (c *MongoClient) ListCollections(ctx context.Context, database string) ([]string, error) {
db := c.client.Database(database)
collections, err := db.ListCollectionNames(ctx, bson.M{})
return collections, err
}
// GetCollectionStructure 获取集合结构
func (c *MongoClient) GetCollectionStructure(ctx context.Context, database, collectionName string) (map[string]interface{}, error) {
coll := c.client.Database(database).Collection(collectionName)
result := map[string]interface{}{
"database": database,
"collection": collectionName,
"sampleDocs": []map[string]interface{}{},
"fieldStats": map[string]int{},
"indexes": []map[string]interface{}{},
"documentCount": int64(0),
}
// 获取文档示例(最多 5 个)
opts := options.Find().SetLimit(5)
cursor, err := coll.Find(ctx, bson.M{}, opts)
if err != nil {
return nil, fmt.Errorf("获取文档示例失败: %v", err)
}
defer cursor.Close(ctx)
var docs []bson.M
if err = cursor.All(ctx, &docs); err != nil {
return nil, fmt.Errorf("解析文档失败: %v", err)
}
// 转换为 map
sampleDocs := make([]map[string]interface{}, 0, len(docs))
for _, doc := range docs {
docMap := make(map[string]interface{})
for k, v := range doc {
docMap[k] = v
}
sampleDocs = append(sampleDocs, docMap)
}
result["sampleDocs"] = sampleDocs
// 字段统计:使用 $sample 聚合管道随机采样10个文档进行统计
// 这样可以获得更准确的字段分布,同时保持良好性能
// 使用异步方式执行,避免阻塞主流程
sampleSize := 10
pipeline := []bson.M{
{"$sample": bson.M{"size": sampleSize}},
{"$project": bson.M{"keys": bson.M{"$objectToArray": "$$ROOT"}}},
{"$unwind": "$keys"},
{"$group": bson.M{
"_id": "$keys.k",
"count": bson.M{"$sum": 1},
}},
{"$sort": bson.M{"count": -1}}, // 按出现次数降序排序
}
sampleCursor, err := coll.Aggregate(ctx, pipeline)
if err != nil {
// 如果采样失败,回退到基于文档示例的统计
fieldCount := make(map[string]int)
for _, doc := range docs {
for key := range doc {
fieldCount[key]++
}
}
result["fieldStats"] = fieldCount
result["fieldStatsSampleSize"] = len(docs) // 记录实际采样数量
result["fieldStatsMethod"] = "sample-docs" // 标记统计方式
} else {
defer sampleCursor.Close(ctx)
fieldCount := make(map[string]int)
for sampleCursor.Next(ctx) {
var statResult bson.M
if err := sampleCursor.Decode(&statResult); err != nil {
continue
}
fieldName, ok := statResult["_id"].(string)
if !ok {
continue
}
var count int
switch v := statResult["count"].(type) {
case int32:
count = int(v)
case int64:
count = int(v)
case int:
count = v
case float64:
count = int(v)
default:
continue
}
fieldCount[fieldName] = count
}
result["fieldStats"] = fieldCount
result["fieldStatsSampleSize"] = sampleSize // 记录采样数量
result["fieldStatsMethod"] = "sample-aggregate" // 标记统计方式
}
// 文档总数(使用估算值,性能更好)
// 对于大数据集estimatedDocumentCount 比 CountDocuments 快得多
// 如果需要精确值,可以使用 CountDocuments但性能较差
count, err := coll.EstimatedDocumentCount(ctx)
if err != nil {
// 如果估算失败,尝试精确计数(可能较慢)
count, err = coll.CountDocuments(ctx, bson.M{})
if err != nil {
return nil, fmt.Errorf("获取文档数量失败: %v", err)
}
}
result["documentCount"] = count
// 索引信息
indexCursor, err := coll.Indexes().List(ctx)
if err != nil {
// 索引查询失败不影响主流程
result["indexes"] = []map[string]interface{}{}
} else {
var indexes []map[string]interface{}
for indexCursor.Next(ctx) {
var indexSpec bson.M
if err := indexCursor.Decode(&indexSpec); err != nil {
continue
}
indexes = append(indexes, map[string]interface{}{
"name": indexSpec["name"],
"unique": indexSpec["unique"],
"keys": indexSpec["key"],
})
}
indexCursor.Close(ctx)
result["indexes"] = indexes
}
return result, nil
}
// ExecuteQuery 执行查询
func (c *MongoClient) ExecuteQuery(ctx context.Context, database, collection string, filter bson.M, limit int64) ([]map[string]interface{}, error) {
db := c.client.Database(database)
coll := db.Collection(collection)
opts := options.Find().SetLimit(limit)
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer cursor.Close(ctx)
var results []map[string]interface{}
if err := cursor.All(ctx, &results); err != nil {
return nil, fmt.Errorf("读取结果失败: %v", err)
}
return results, nil
}
// CountDocuments 获取文档数量
func (c *MongoClient) CountDocuments(ctx context.Context, database, collection string, filter bson.M) (int64, error) {
db := c.client.Database(database)
coll := db.Collection(collection)
return coll.CountDocuments(ctx, filter)
}
// ExecuteCommand 执行 MongoDB 命令
// command 可以是 JSON 格式的字符串,格式:{"op": "find", "database": "test", "collection": "users", "filter": {}, "limit": 100}
// 支持的操作find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany
func (c *MongoClient) ExecuteCommand(ctx context.Context, database string, command map[string]interface{}) (interface{}, error) {
op, ok := command["op"].(string)
if !ok {
return nil, fmt.Errorf("命令中缺少 'op' 字段或格式错误")
}
collectionName, ok := command["collection"].(string)
if !ok {
return nil, fmt.Errorf("命令中缺少 'collection' 字段或格式错误")
}
// 如果没有指定数据库,使用配置中的默认数据库
if database == "" {
if c.config != nil && c.config.Database != "" {
database = c.config.Database
} else {
return nil, fmt.Errorf("需要指定数据库名称")
}
}
db := c.client.Database(database)
coll := db.Collection(collectionName)
switch op {
case "find":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
}
limit := int64(100)
if l, ok := command["limit"]; ok {
if limitVal, ok := l.(float64); ok {
limit = int64(limitVal)
} else if limitVal, ok := l.(int64); ok {
limit = limitVal
}
}
opts := options.Find().SetLimit(limit)
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer cursor.Close(ctx)
var results []map[string]interface{}
if err := cursor.All(ctx, &results); err != nil {
return nil, fmt.Errorf("读取结果失败: %v", err)
}
return results, nil
case "count":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
}
count, err := coll.CountDocuments(ctx, filter)
if err != nil {
return nil, fmt.Errorf("统计失败: %v", err)
}
return count, nil
case "insertOne":
document, ok := command["document"]
if !ok {
return nil, fmt.Errorf("insertOne 操作需要 'document' 字段")
}
doc := bson.M{}
if docMap, ok := document.(map[string]interface{}); ok {
doc = bson.M(docMap)
} else {
return nil, fmt.Errorf("document 必须是对象格式")
}
result, err := coll.InsertOne(ctx, doc)
if err != nil {
return nil, fmt.Errorf("插入失败: %v", err)
}
return map[string]interface{}{
"insertedId": result.InsertedID,
}, nil
case "insertMany":
documents, ok := command["documents"]
if !ok {
return nil, fmt.Errorf("insertMany 操作需要 'documents' 字段")
}
docs := []interface{}{}
if docsSlice, ok := documents.([]interface{}); ok {
for _, d := range docsSlice {
if docMap, ok := d.(map[string]interface{}); ok {
docs = append(docs, bson.M(docMap))
}
}
} else {
return nil, fmt.Errorf("documents 必须是数组格式")
}
result, err := coll.InsertMany(ctx, docs)
if err != nil {
return nil, fmt.Errorf("批量插入失败: %v", err)
}
return map[string]interface{}{
"insertedIds": result.InsertedIDs,
"insertedCount": len(result.InsertedIDs),
}, nil
case "updateOne":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
} else {
return nil, fmt.Errorf("updateOne 操作需要 'filter' 字段")
}
update, ok := command["update"]
if !ok {
return nil, fmt.Errorf("updateOne 操作需要 'update' 字段")
}
updateDoc := bson.M{}
if updateMap, ok := update.(map[string]interface{}); ok {
updateDoc = bson.M(updateMap)
} else {
return nil, fmt.Errorf("update 必须是对象格式")
}
result, err := coll.UpdateOne(ctx, filter, bson.M{"$set": updateDoc})
if err != nil {
return nil, fmt.Errorf("更新失败: %v", err)
}
return map[string]interface{}{
"matchedCount": result.MatchedCount,
"modifiedCount": result.ModifiedCount,
}, nil
case "updateMany":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
} else {
return nil, fmt.Errorf("updateMany 操作需要 'filter' 字段")
}
update, ok := command["update"]
if !ok {
return nil, fmt.Errorf("updateMany 操作需要 'update' 字段")
}
updateDoc := bson.M{}
if updateMap, ok := update.(map[string]interface{}); ok {
updateDoc = bson.M(updateMap)
} else {
return nil, fmt.Errorf("update 必须是对象格式")
}
result, err := coll.UpdateMany(ctx, filter, bson.M{"$set": updateDoc})
if err != nil {
return nil, fmt.Errorf("批量更新失败: %v", err)
}
return map[string]interface{}{
"matchedCount": result.MatchedCount,
"modifiedCount": result.ModifiedCount,
}, nil
case "deleteOne":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
} else {
return nil, fmt.Errorf("deleteOne 操作需要 'filter' 字段")
}
result, err := coll.DeleteOne(ctx, filter)
if err != nil {
return nil, fmt.Errorf("删除失败: %v", err)
}
return map[string]interface{}{
"deletedCount": result.DeletedCount,
}, nil
case "deleteMany":
filter := bson.M{}
if f, ok := command["filter"]; ok {
if filterMap, ok := f.(map[string]interface{}); ok {
filter = bson.M(filterMap)
}
} else {
return nil, fmt.Errorf("deleteMany 操作需要 'filter' 字段")
}
result, err := coll.DeleteMany(ctx, filter)
if err != nil {
return nil, fmt.Errorf("批量删除失败: %v", err)
}
return map[string]interface{}{
"deletedCount": result.DeletedCount,
}, nil
default:
return nil, fmt.Errorf("不支持的操作: %s支持的操作: find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany", op)
}
}
// PreviewCollectionIndexes 预览集合索引变更,只生成命令列表不执行
func (c *MongoClient) PreviewCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
coll := c.client.Database(database).Collection(collectionName)
var commands []string
// 获取当前索引
currentIndexes, err := coll.Indexes().List(ctx)
if err != nil {
return nil, fmt.Errorf("获取当前索引失败: %v", err)
}
defer currentIndexes.Close(ctx)
// 解析新的索引数据
var newIndexes []map[string]interface{}
if idxs, ok := structure["indexes"].([]interface{}); ok {
for _, idx := range idxs {
if idxMap, ok := idx.(map[string]interface{}); ok {
newIndexes = append(newIndexes, idxMap)
}
}
}
// 创建当前索引名映射
currentIndexMap := make(map[string]bool)
for currentIndexes.Next(ctx) {
var indexSpec bson.M
if err := currentIndexes.Decode(&indexSpec); err != nil {
continue
}
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
currentIndexMap[name] = true
}
}
// 创建新索引名映射
newIndexMap := make(map[string]bool)
for _, idx := range newIndexes {
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
newIndexMap[name] = true
}
}
// 删除不存在的索引
for name := range currentIndexMap {
if !newIndexMap[name] {
cmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
commands = append(commands, cmd)
}
}
// 添加或更新索引
for _, idx := range newIndexes {
name, _ := idx["name"].(string)
if name == "" || name == "_id_" {
continue
}
// 构建索引键
keys := bson.D{}
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
for k, v := range keysData {
var order int
if vFloat, ok := v.(float64); ok {
order = int(vFloat)
} else if vInt, ok := v.(int); ok {
order = vInt
} else {
order = 1 // 默认升序
}
keys = append(keys, bson.E{Key: k, Value: order})
}
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
// 兼容 MySQL 格式的索引数据
keys = append(keys, bson.E{Key: columnName, Value: 1})
}
if len(keys) == 0 {
continue
}
// 构建索引选项
indexOptions := options.Index()
indexOptions.SetName(name)
if unique, ok := idx["unique"].(bool); ok && unique {
indexOptions.SetUnique(true)
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
indexOptions.SetUnique(true)
}
// 如果索引已存在,先删除再创建
if currentIndexMap[name] {
dropCmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
commands = append(commands, dropCmd)
}
// 构建命令字符串MongoDB shell 格式)
keysStr := "{"
for i, key := range keys {
if i > 0 {
keysStr += ", "
}
keysStr += fmt.Sprintf("%s: %d", key.Key, key.Value)
}
keysStr += "}"
optionsStr := "{name: \"" + name + "\""
if indexOptions.Unique != nil && *indexOptions.Unique {
optionsStr += ", unique: true"
}
optionsStr += "}"
cmd := fmt.Sprintf("db.%s.createIndex(%s, %s)", collectionName, keysStr, optionsStr)
commands = append(commands, cmd)
}
return commands, nil
}
// UpdateCollectionIndexes 更新集合索引,返回执行的命令列表
func (c *MongoClient) UpdateCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
// 先预览生成命令列表
commands, err := c.PreviewCollectionIndexes(ctx, database, collectionName, structure)
if err != nil {
return nil, err
}
coll := c.client.Database(database).Collection(collectionName)
// 获取当前索引
currentIndexes, err := coll.Indexes().List(ctx)
if err != nil {
return commands, fmt.Errorf("获取当前索引失败: %v", err)
}
defer currentIndexes.Close(ctx)
// 解析新的索引数据
var newIndexes []map[string]interface{}
if idxs, ok := structure["indexes"].([]interface{}); ok {
for _, idx := range idxs {
if idxMap, ok := idx.(map[string]interface{}); ok {
newIndexes = append(newIndexes, idxMap)
}
}
}
// 创建当前索引名映射
currentIndexMap := make(map[string]bool)
for currentIndexes.Next(ctx) {
var indexSpec bson.M
if err := currentIndexes.Decode(&indexSpec); err != nil {
continue
}
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
currentIndexMap[name] = true
}
}
// 创建新索引名映射
newIndexMap := make(map[string]bool)
for _, idx := range newIndexes {
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
newIndexMap[name] = true
}
}
// 删除不存在的索引
for name := range currentIndexMap {
if !newIndexMap[name] {
_, err := coll.Indexes().DropOne(ctx, name)
if err != nil {
return commands, fmt.Errorf("删除索引失败: %v, 索引名: %s", err, name)
}
}
}
// 添加或更新索引
for _, idx := range newIndexes {
name, _ := idx["name"].(string)
if name == "" || name == "_id_" {
continue
}
// 构建索引键
keys := bson.D{}
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
for k, v := range keysData {
var order int
if vFloat, ok := v.(float64); ok {
order = int(vFloat)
} else if vInt, ok := v.(int); ok {
order = vInt
} else {
order = 1 // 默认升序
}
keys = append(keys, bson.E{Key: k, Value: order})
}
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
// 兼容 MySQL 格式的索引数据
keys = append(keys, bson.E{Key: columnName, Value: 1})
}
if len(keys) == 0 {
continue
}
// 构建索引选项
indexOptions := options.Index()
indexOptions.SetName(name)
if unique, ok := idx["unique"].(bool); ok && unique {
indexOptions.SetUnique(true)
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
indexOptions.SetUnique(true)
}
// 创建索引
indexModel := mongo.IndexModel{
Keys: keys,
Options: indexOptions,
}
// 如果索引已存在,先删除再创建
if currentIndexMap[name] {
_, err := coll.Indexes().DropOne(ctx, name)
if err != nil {
return commands, fmt.Errorf("删除旧索引失败: %v, 索引名: %s", err, name)
}
}
_, err := coll.Indexes().CreateOne(ctx, indexModel)
if err != nil {
return commands, fmt.Errorf("创建索引失败: %v, 索引名: %s", err, name)
}
}
return commands, nil
}

875
internal/dbclient/mysql.go Normal file
View File

@@ -0,0 +1,875 @@
package dbclient
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// MySQLClient MySQL 客户端
type MySQLClient struct {
db *gorm.DB
sqlDB *sql.DB
config *MySQLConfig
}
// MySQLConfig MySQL 配置
type MySQLConfig struct {
Host string
Port int
Username string
Password string
Database string
}
// NewMySQLClient 创建 MySQL 客户端
func NewMySQLClient(config *MySQLConfig) (*MySQLClient, error) {
// 构建 DSN
mysqlConfig := mysqldriver.Config{
User: config.Username,
Passwd: config.Password,
Net: "tcp",
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
DBName: config.Database,
Params: map[string]string{
"charset": "utf8mb4",
"parseTime": "True",
"loc": "Local",
"multiStatements": "true", // 支持多条SQL语句执行
},
AllowNativePasswords: true,
Timeout: 5 * time.Second,
}
dsn := mysqlConfig.FormatDSN()
// GORM 配置
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
// 打开连接
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
if err != nil {
return nil, fmt.Errorf("连接 MySQL 失败: %v", err)
}
// 获取底层 sql.DB
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取数据库实例失败: %v", err)
}
// 测试连接
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("MySQL 连接测试失败: %v", err)
}
// 设置连接池参数
sqlDB.SetMaxOpenConns(10)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return &MySQLClient{
db: db,
sqlDB: sqlDB,
config: config,
}, nil
}
// TestConnection 测试连接
func TestMySQLConnection(host string, port int, username, password, database string) error {
config := &MySQLConfig{
Host: host,
Port: port,
Username: username,
Password: password,
Database: database,
}
client, err := NewMySQLClient(config)
if err != nil {
return err
}
defer client.Close()
return nil
}
// Close 关闭连接
func (c *MySQLClient) Close() error {
if c.sqlDB != nil {
return c.sqlDB.Close()
}
return nil
}
// QueryResult 查询结果,包含数据和列顺序
type QueryResult struct {
Data []map[string]interface{}
Columns []string
}
// ExecuteQuery 执行查询 SQL
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
// 注意SQL 语句应该已经包含 LIMIT 和 OFFSET由客户端添加
func (c *MySQLClient) ExecuteQuery(ctx context.Context, sqlStr string, database string) (*QueryResult, error) {
// 确定要使用的数据库
dbName := database
if dbName == "" {
dbName = c.config.Database
}
// 使用 Session 创建独立的数据库会话,避免影响其他查询
db := c.db.Session(&gorm.Session{})
// 如果指定了数据库,先切换到该数据库
if dbName != "" {
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
return nil, fmt.Errorf("切换数据库失败: %v", err)
}
}
rows, err := db.Raw(sqlStr).Rows()
if err != nil {
return nil, fmt.Errorf("执行查询失败: %v", err)
}
defer rows.Close()
// 检查 rows 错误
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("查询结果错误: %v", err)
}
// 获取列名
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("获取列名失败: %v", err)
}
// 如果没有列,返回空数组
if len(columns) == 0 {
return &QueryResult{
Data: []map[string]interface{}{},
Columns: []string{},
}, nil
}
// 读取数据
var results []map[string]interface{}
for rows.Next() {
// 创建值数组和指针数组
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
// 扫描行数据
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("扫描数据失败: %v", err)
}
// 构建结果 map按照列顺序构建
row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
// 处理 nil 值
if val == nil {
row[col] = nil
} else if b, ok := val.([]byte); ok {
// 处理 []byte 类型
row[col] = string(b)
} else {
row[col] = val
}
}
results = append(results, row)
}
// 检查迭代过程中的错误
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取数据时发生错误: %v", err)
}
return &QueryResult{
Data: results,
Columns: columns,
}, nil
}
// ExecuteUpdate 执行更新 SQLINSERT/UPDATE/DELETE
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
func (c *MySQLClient) ExecuteUpdate(ctx context.Context, sqlStr string, database string) (int64, error) {
// 确定要使用的数据库
dbName := database
if dbName == "" {
dbName = c.config.Database
}
// 使用 Session 创建独立的数据库会话,避免影响其他查询
db := c.db.Session(&gorm.Session{})
// 如果指定了数据库,先切换到该数据库
if dbName != "" {
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
return 0, fmt.Errorf("切换数据库失败: %v", err)
}
}
result := db.Exec(sqlStr)
if result.Error != nil {
return 0, fmt.Errorf("执行更新失败: %v", result.Error)
}
return result.RowsAffected, nil
}
// ListDatabases 获取数据库列表
func (c *MySQLClient) ListDatabases(ctx context.Context) ([]string, error) {
var databases []string
err := c.db.Raw("SHOW DATABASES").Scan(&databases).Error
return databases, err
}
// ListTables 获取表列表
func (c *MySQLClient) ListTables(ctx context.Context, database string) ([]string, error) {
var tables []string
query := "SHOW TABLES"
if database != "" {
query = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
}
err := c.db.Raw(query).Scan(&tables).Error
return tables, err
}
// GetTableStructure 获取表结构
func (c *MySQLClient) GetTableStructure(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
// 使用 SHOW FULL COLUMNS 来获取包含 comment 的完整字段信息
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
if database != "" {
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", database, tableName)
}
rows, err := c.db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("获取表结构失败: %v", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("获取列名失败: %v", err)
}
var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("扫描数据失败: %v", err)
}
row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else if val == nil {
row[col] = nil
} else {
row[col] = val
}
}
// 确保 Comment 字段存在SHOW FULL COLUMNS 返回的字段名是 Comment
if _, ok := row["Comment"]; !ok {
row["Comment"] = ""
}
results = append(results, row)
}
return results, nil
}
// GetIndexes 获取索引列表
func (c *MySQLClient) GetIndexes(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
query := "SHOW INDEX FROM "
if database != "" {
query += fmt.Sprintf("`%s`.", database)
}
query += fmt.Sprintf("`%s`", tableName)
rows, err := c.db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("获取索引列表失败: %v", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("获取列名失败: %v", err)
}
var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("扫描数据失败: %v", err)
}
row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
results = append(results, row)
}
return results, nil
}
// PreviewTableStructure 预览表结构变更,只生成 SQL 语句不执行
func (c *MySQLClient) PreviewTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
// 获取当前表结构
currentColumns, err := c.GetTableStructure(ctx, database, tableName)
if err != nil {
return nil, fmt.Errorf("获取当前表结构失败: %v", err)
}
currentIndexes, err := c.GetIndexes(ctx, database, tableName)
if err != nil {
return nil, fmt.Errorf("获取当前索引失败: %v", err)
}
// 解析新的结构数据
var newColumns []map[string]interface{}
var newIndexes []map[string]interface{}
if cols, ok := structure["columns"].([]interface{}); ok {
for _, col := range cols {
if colMap, ok := col.(map[string]interface{}); ok {
newColumns = append(newColumns, colMap)
}
}
}
if idxs, ok := structure["indexes"].([]interface{}); ok {
for _, idx := range idxs {
if idxMap, ok := idx.(map[string]interface{}); ok {
newIndexes = append(newIndexes, idxMap)
}
}
}
// 构建 ALTER TABLE 语句
var alterStatements []string
// 处理字段变更
alterStatements = append(alterStatements, c.buildColumnAlterStatements(tableName, currentColumns, newColumns)...)
// 处理索引变更
alterStatements = append(alterStatements, c.buildIndexAlterStatements(tableName, currentIndexes, newIndexes)...)
return alterStatements, nil
}
// UpdateTableStructure 更新表结构,返回生成的 SQL 语句列表
func (c *MySQLClient) UpdateTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
// 先预览生成 SQL 语句
alterStatements, err := c.PreviewTableStructure(ctx, database, tableName, structure)
if err != nil {
return nil, err
}
// 执行所有 ALTER TABLE 语句
if len(alterStatements) > 0 {
dbName := database
if dbName == "" {
dbName = c.config.Database
}
db := c.db.Session(&gorm.Session{})
if dbName != "" {
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
return alterStatements, fmt.Errorf("切换数据库失败: %v", err)
}
}
for _, stmt := range alterStatements {
if err := db.Exec(stmt).Error; err != nil {
return alterStatements, fmt.Errorf("执行 ALTER TABLE 失败: %v, SQL: %s", err, stmt)
}
}
}
return alterStatements, nil
}
// buildColumnAlterStatements 构建字段变更的 ALTER TABLE 语句
func (c *MySQLClient) buildColumnAlterStatements(tableName string, currentColumns, newColumns []map[string]interface{}) []string {
var statements []string
// 创建字段名映射和顺序映射
currentFieldMap := make(map[string]map[string]interface{})
currentFieldOrder := make([]string, 0, len(currentColumns))
for _, col := range currentColumns {
if field, ok := col["Field"].(string); ok {
currentFieldMap[field] = col
currentFieldOrder = append(currentFieldOrder, field)
}
}
newFieldMap := make(map[string]bool)
newFieldOrder := make([]string, 0, len(newColumns))
newColumnsMap := make(map[string]map[string]interface{})
for _, col := range newColumns {
if field, ok := col["Field"].(string); ok && field != "" {
newFieldMap[field] = true
newFieldOrder = append(newFieldOrder, field)
newColumnsMap[field] = col
}
}
// 检测字段重命名:优先使用位置匹配,如果位置相同但字段名不同,认为是重命名
renameMap := make(map[string]string) // oldName -> newName
processedNewFields := make(map[string]bool)
// 第一步:使用位置匹配检测重命名(最可靠)
for oldIndex, oldFieldName := range currentFieldOrder {
if newFieldMap[oldFieldName] {
continue // 字段名未改变,跳过
}
// 检查新字段列表中相同位置是否有字段
if oldIndex < len(newFieldOrder) {
newFieldName := newFieldOrder[oldIndex]
_, existsInCurrent := currentFieldMap[newFieldName]
if !existsInCurrent && !processedNewFields[newFieldName] {
// 新字段不在当前字段列表中,且位置相同,很可能是重命名
// 进一步验证:检查类型是否相同(类型相同更可能是重命名)
oldCol := currentFieldMap[oldFieldName]
newCol := newColumnsMap[newFieldName]
oldType := getStringValue(oldCol["Type"])
newType := getStringValue(newCol["Type"])
// 如果类型相同,认为是重命名
if oldType == newType {
renameMap[oldFieldName] = newFieldName
processedNewFields[newFieldName] = true
continue
}
}
}
}
// 第二步:对于未匹配的字段,使用属性匹配(兼容旧逻辑)
for oldFieldName, oldCol := range currentFieldMap {
if newFieldMap[oldFieldName] {
continue // 字段名未改变,跳过
}
if renameMap[oldFieldName] != "" {
continue // 已经通过位置匹配识别为重命名
}
// 查找属性完全匹配的新字段
var matchedNewField string
for newFieldName, newCol := range newColumnsMap {
if processedNewFields[newFieldName] {
continue // 已经被匹配过了
}
_, existsInCurrent := currentFieldMap[newFieldName]
if !existsInCurrent {
// 这是一个新增字段,检查属性是否匹配
if c.isColumnPropertiesEqual(oldCol, newCol) {
if matchedNewField == "" {
matchedNewField = newFieldName
} else {
// 有多个匹配,无法确定,不认为是重命名
matchedNewField = ""
break
}
}
}
}
// 如果找到唯一匹配,认为是重命名
if matchedNewField != "" {
renameMap[oldFieldName] = matchedNewField
processedNewFields[matchedNewField] = true
}
}
// 处理字段重命名
for oldName, newName := range renameMap {
stmt := fmt.Sprintf("ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`", tableName, oldName, newName)
statements = append(statements, stmt)
}
// 处理字段添加、修改和位置调整(排除已重命名的字段)
for i, newCol := range newColumns {
field, _ := newCol["Field"].(string)
if field == "" {
continue
}
// 检查是否是重命名的字段
isRenamed := false
var oldName string
for old, new := range renameMap {
if new == field {
isRenamed = true
oldName = old
break
}
}
if isRenamed {
// 重命名的字段:如果属性有变化,需要 MODIFY COLUMN
oldCol := currentFieldMap[oldName]
needsModify := c.isColumnChanged(oldCol, newCol)
// 检查顺序变化:使用旧字段名在 currentOrder 中查找位置,与新位置比较
oldIndex := -1
for idx, name := range currentFieldOrder {
if name == oldName {
oldIndex = idx
break
}
}
needsReorder := (oldIndex != -1 && oldIndex != i)
if needsModify || needsReorder {
// 重命名后需要修改属性或位置
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
if stmt != "" {
statements = append(statements, stmt)
}
}
continue
}
if currentCol, exists := currentFieldMap[field]; exists {
// 修改现有字段
needsModify := c.isColumnChanged(currentCol, newCol)
needsReorder := c.isColumnOrderChanged(currentFieldOrder, newFieldOrder, field, i)
if needsModify || needsReorder {
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
if stmt != "" {
statements = append(statements, stmt)
}
}
} else {
// 添加新字段(排除重命名的字段)
stmt := c.buildAddColumnStatement(tableName, newCol, newFieldOrder, i)
if stmt != "" {
statements = append(statements, stmt)
}
}
}
// 删除不存在的字段(排除已重命名的字段)
for field := range currentFieldMap {
if !newFieldMap[field] && renameMap[field] == "" {
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN `%s`", tableName, field)
statements = append(statements, stmt)
}
}
return statements
}
// buildIndexAlterStatements 构建索引变更的 ALTER TABLE 语句
func (c *MySQLClient) buildIndexAlterStatements(tableName string, currentIndexes, newIndexes []map[string]interface{}) []string {
var statements []string
// 创建索引名映射
currentIndexMap := make(map[string]map[string]interface{})
for _, idx := range currentIndexes {
if keyName, ok := idx["Key_name"].(string); ok && keyName != "PRIMARY" {
currentIndexMap[keyName] = idx
}
}
newIndexMap := make(map[string]bool)
for _, idx := range newIndexes {
if keyName, ok := idx["Key_name"].(string); ok && keyName != "" && keyName != "PRIMARY" {
newIndexMap[keyName] = true
}
}
// 处理索引变更
for _, newIdx := range newIndexes {
keyName, _ := newIdx["Key_name"].(string)
if keyName == "" || keyName == "PRIMARY" {
continue
}
if currentIdx, exists := currentIndexMap[keyName]; exists {
// 修改现有索引
if c.isIndexChanged(currentIdx, newIdx) {
dropStmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
addStmt := c.buildAddIndexStatement(tableName, newIdx)
if addStmt != "" {
statements = append(statements, dropStmt)
statements = append(statements, addStmt)
}
}
} else {
// 添加新索引
stmt := c.buildAddIndexStatement(tableName, newIdx)
if stmt != "" {
statements = append(statements, stmt)
}
}
}
// 删除不存在的索引
for keyName := range currentIndexMap {
if !newIndexMap[keyName] {
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
statements = append(statements, stmt)
}
}
return statements
}
// isColumnChanged 检查字段是否发生变化(不包括字段名)
func (c *MySQLClient) isColumnChanged(oldCol, newCol map[string]interface{}) bool {
fields := []string{"Type", "Null", "Default", "Extra", "Comment"}
for _, field := range fields {
oldVal := getStringValue(oldCol[field])
newVal := getStringValue(newCol[field])
if oldVal != newVal {
return true
}
}
return false
}
// isColumnPropertiesEqual 检查字段属性是否完全相等(不包括字段名)
func (c *MySQLClient) isColumnPropertiesEqual(oldCol, newCol map[string]interface{}) bool {
fields := []string{"Type", "Null", "Default", "Extra", "Key", "Comment"}
for _, field := range fields {
oldVal := getStringValue(oldCol[field])
newVal := getStringValue(newCol[field])
if oldVal != newVal {
return false
}
}
return true
}
// isColumnOrderChanged 检查字段顺序是否发生变化
func (c *MySQLClient) isColumnOrderChanged(currentOrder, newOrder []string, fieldName string, newIndex int) bool {
// 查找字段在当前顺序中的位置
currentIndex := -1
for i, name := range currentOrder {
if name == fieldName {
currentIndex = i
break
}
}
// 如果字段不存在于当前顺序中(新字段),不需要检查顺序
if currentIndex == -1 {
return false
}
// 如果索引相同,检查前面的字段是否相同
if newIndex == currentIndex {
// 检查前面的字段集合是否相同
if newIndex > 0 {
currentPrevFields := make(map[string]bool)
for i := 0; i < currentIndex; i++ {
currentPrevFields[currentOrder[i]] = true
}
newPrevFields := make(map[string]bool)
for i := 0; i < newIndex; i++ {
newPrevFields[newOrder[i]] = true
}
// 如果前面的字段集合不同,说明顺序变了
if len(currentPrevFields) != len(newPrevFields) {
return true
}
for f := range currentPrevFields {
if !newPrevFields[f] {
return true
}
}
}
return false
}
// 索引不同,说明顺序变了
return true
}
// isIndexChanged 检查索引是否发生变化
func (c *MySQLClient) isIndexChanged(oldIdx, newIdx map[string]interface{}) bool {
oldCol := getStringValue(oldIdx["Column_name"])
newCol := getStringValue(newIdx["Column_name"])
if oldCol != newCol {
return true
}
oldUnique := getIntValue(oldIdx["Non_unique"])
newUnique := getIntValue(newIdx["Non_unique"])
return oldUnique != newUnique
}
// buildAddColumnStatement 构建添加字段的语句
func (c *MySQLClient) buildAddColumnStatement(tableName string, col map[string]interface{}, fieldOrder []string, index int) string {
field := getStringValue(col["Field"])
if field == "" {
return ""
}
colDef := c.buildColumnDefinition(col)
// 确定字段位置
position := c.buildColumnPosition(fieldOrder, index)
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN %s%s", tableName, colDef, position)
}
// buildModifyColumnStatement 构建修改字段的语句
func (c *MySQLClient) buildModifyColumnStatement(tableName, field string, col map[string]interface{}, fieldOrder []string, index int) string {
colDef := c.buildColumnDefinition(col)
// 确定字段位置
position := c.buildColumnPosition(fieldOrder, index)
return fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s%s", tableName, colDef, position)
}
// buildColumnPosition 构建字段位置子句AFTER 或 FIRST
func (c *MySQLClient) buildColumnPosition(fieldOrder []string, index int) string {
if index < 0 || index >= len(fieldOrder) {
return ""
}
if index == 0 {
// 第一个字段使用 FIRST
return " FIRST"
}
// 其他字段使用 AFTER 前一个字段
prevField := fieldOrder[index-1]
return fmt.Sprintf(" AFTER `%s`", prevField)
}
// buildColumnDefinition 构建字段定义
func (c *MySQLClient) buildColumnDefinition(col map[string]interface{}) string {
field := getStringValue(col["Field"])
colType := getStringValue(col["Type"])
null := getStringValue(col["Null"])
defaultVal := col["Default"]
extra := getStringValue(col["Extra"])
comment := getStringValue(col["Comment"])
def := fmt.Sprintf("`%s` %s", field, colType)
if null == "NO" {
def += " NOT NULL"
}
if defaultVal != nil {
if defaultStr, ok := defaultVal.(string); ok {
if defaultStr == "" {
// 空字符串表示默认值为空字符串
def += " DEFAULT ''"
} else if defaultStr != "NULL" {
// 转义单引号
escapedDefault := strings.ReplaceAll(defaultStr, "'", "''")
def += fmt.Sprintf(" DEFAULT '%s'", escapedDefault)
}
// 如果 defaultStr == "NULL",不添加 DEFAULT 子句(允许 NULL
} else {
// 非字符串类型的默认值
def += fmt.Sprintf(" DEFAULT %v", defaultVal)
}
}
if extra != "" {
def += " " + extra
}
if comment != "" {
// 转义单引号
escapedComment := strings.ReplaceAll(comment, "'", "''")
def += fmt.Sprintf(" COMMENT '%s'", escapedComment)
}
return def
}
// buildAddIndexStatement 构建添加索引的语句
func (c *MySQLClient) buildAddIndexStatement(tableName string, idx map[string]interface{}) string {
keyName := getStringValue(idx["Key_name"])
columnName := getStringValue(idx["Column_name"])
nonUnique := getIntValue(idx["Non_unique"])
if keyName == "" || columnName == "" {
return ""
}
indexType := "INDEX"
if nonUnique == 0 {
indexType = "UNIQUE INDEX"
}
return fmt.Sprintf("ALTER TABLE `%s` ADD %s `%s` (`%s`)", tableName, indexType, keyName, columnName)
}
// getStringValue 安全获取字符串值
func getStringValue(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// getIntValue 安全获取整数值
func getIntValue(v interface{}) int {
if v == nil {
return 0
}
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var i int
fmt.Sscanf(val, "%d", &i)
return i
}
return 0
}

236
internal/dbclient/pool.go Normal file
View File

@@ -0,0 +1,236 @@
package dbclient
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"go-desk/internal/crypto"
"go-desk/internal/storage/models"
)
// ConnectionPool 连接池管理器
type ConnectionPool struct {
mysqlClients map[uint]*MySQLClient
redisClients map[uint]*RedisClient
mongoClients map[uint]*MongoClient
mu sync.RWMutex
}
var (
globalPool *ConnectionPool
poolOnce sync.Once
)
// GetPool 获取全局连接池实例
func GetPool() *ConnectionPool {
poolOnce.Do(func() {
globalPool = &ConnectionPool{
mysqlClients: make(map[uint]*MySQLClient),
redisClients: make(map[uint]*RedisClient),
mongoClients: make(map[uint]*MongoClient),
}
})
return globalPool
}
// GetMySQLClient 获取或创建 MySQL 客户端
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 检查是否已存在
if client, ok := p.mysqlClients[conn.ID]; ok {
// 测试连接是否有效
if err := client.sqlDB.Ping(); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.mysqlClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 创建新客户端
config := &MySQLConfig{
Host: conn.Host,
Port: conn.Port,
Username: conn.Username,
Password: password, // 如果密码为空MySQL会尝试无密码连接
Database: conn.Database,
}
client, err := NewMySQLClient(config)
if err != nil {
return nil, err
}
p.mysqlClients[conn.ID] = client
return client, nil
}
// GetRedisClient 获取或创建 Redis 客户端
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 检查是否已存在
if client, ok := p.redisClients[conn.ID]; ok {
// 测试连接是否有效
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.client.Ping(ctx).Err(); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.redisClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 解析 Redis DB 编号(从 Database 字段,默认为 0
dbNum := 0
if conn.Database != "" {
// 尝试解析 Database 字段为数字
_, err := fmt.Sscanf(conn.Database, "%d", &dbNum)
if err != nil {
// 如果解析失败,使用默认值 0
dbNum = 0
}
// 限制 DB 编号在 0-15 之间
if dbNum < 0 || dbNum > 15 {
dbNum = 0
}
}
// 创建新客户端
config := &RedisConfig{
Host: conn.Host,
Port: conn.Port,
Password: password,
DB: dbNum,
}
client, err := NewRedisClient(config)
if err != nil {
return nil, err
}
p.redisClients[conn.ID] = client
return client, nil
}
// GetMongoClient 获取或创建 MongoDB 客户端
func (p *ConnectionPool) GetMongoClient(conn *models.DbConnection) (*MongoClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 检查是否已存在
if client, ok := p.mongoClients[conn.ID]; ok {
// 测试连接是否有效
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.client.Ping(ctx, nil); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.mongoClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 解析 Options 获取 MongoDB 连接参数
authSource := ""
authMechanism := ""
if conn.Options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
if as, ok := opts["authSource"].(string); ok && as != "" {
authSource = as
}
if am, ok := opts["authMechanism"].(string); ok && am != "" {
authMechanism = am
}
}
}
// 创建新客户端
config := &MongoConfig{
Host: conn.Host,
Port: conn.Port,
Username: conn.Username,
Password: password,
Database: conn.Database,
AuthSource: authSource,
AuthMechanism: authMechanism,
}
client, err := NewMongoClient(config)
if err != nil {
return nil, err
}
p.mongoClients[conn.ID] = client
return client, nil
}
// CloseConnection 关闭指定连接
func (p *ConnectionPool) CloseConnection(connID uint, dbType string) {
p.mu.Lock()
defer p.mu.Unlock()
switch dbType {
case "mysql":
if client, ok := p.mysqlClients[connID]; ok {
client.Close()
delete(p.mysqlClients, connID)
}
case "redis":
if client, ok := p.redisClients[connID]; ok {
client.Close()
delete(p.redisClients, connID)
}
case "mongo":
if client, ok := p.mongoClients[connID]; ok {
client.Close()
delete(p.mongoClients, connID)
}
}
}
// CloseAll 关闭所有连接
func (p *ConnectionPool) CloseAll() {
p.mu.Lock()
defer p.mu.Unlock()
for _, client := range p.mysqlClients {
client.Close()
}
for _, client := range p.redisClients {
client.Close()
}
for _, client := range p.mongoClients {
client.Close()
}
p.mysqlClients = make(map[uint]*MySQLClient)
p.redisClients = make(map[uint]*RedisClient)
p.mongoClients = make(map[uint]*MongoClient)
}

239
internal/dbclient/redis.go Normal file
View File

@@ -0,0 +1,239 @@
package dbclient
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisClient Redis 客户端
type RedisClient struct {
client *redis.Client
config *RedisConfig
}
// RedisConfig Redis 配置
type RedisConfig struct {
Host string
Port int
Password string
DB int // 数据库编号,默认 0
}
// NewRedisClient 创建 Redis 客户端
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: config.Password,
DB: config.DB,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})
// 测试连接
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis 连接测试失败: %v", err)
}
return &RedisClient{
client: rdb,
config: config,
}, nil
}
// TestRedisConnection 测试连接
func TestRedisConnection(host string, port int, password string) error {
config := &RedisConfig{
Host: host,
Port: port,
Password: password,
DB: 0,
}
client, err := NewRedisClient(config)
if err != nil {
return err
}
defer client.Close()
return nil
}
// NewRedisClientByDB 根据参数创建指定 DB 的 Redis 客户端(用于多 DB 场景)
func NewRedisClientByDB(host string, port int, password string, dbNum int) (*RedisClient, error) {
config := &RedisConfig{
Host: host,
Port: port,
Password: password,
DB: dbNum,
}
return NewRedisClient(config)
}
// Close 关闭连接
func (c *RedisClient) Close() error {
if c.client != nil {
return c.client.Close()
}
return nil
}
// ExecuteCommand 执行 Redis 命令
func (c *RedisClient) ExecuteCommand(ctx context.Context, cmd string, args ...interface{}) (interface{}, error) {
return c.client.Do(ctx, append([]interface{}{cmd}, args...)...).Result()
}
// GetKeys 获取 Key 列表(支持 pattern使用 SCAN 代替 KEYS 以提高性能)
func (c *RedisClient) GetKeys(ctx context.Context, pattern string) ([]string, error) {
if pattern == "" {
pattern = "*"
}
var keys []string
var cursor uint64
const count = 100 // 每次扫描的数量
for {
var err error
var batch []string
batch, cursor, err = c.client.Scan(ctx, cursor, pattern, count).Result()
if err != nil {
return nil, err
}
keys = append(keys, batch...)
if cursor == 0 {
break
}
}
return keys, nil
}
// GetKeyType 获取 Key 类型
func (c *RedisClient) GetKeyType(ctx context.Context, key string) (string, error) {
return c.client.Type(ctx, key).Result()
}
// GetKeyValue 获取 Key 值
func (c *RedisClient) GetKeyValue(ctx context.Context, key string) (interface{}, error) {
keyType, err := c.GetKeyType(ctx, key)
if err != nil {
return nil, err
}
switch keyType {
case "string":
return c.client.Get(ctx, key).Result()
case "list":
return c.client.LRange(ctx, key, 0, -1).Result()
case "set":
return c.client.SMembers(ctx, key).Result()
case "zset":
// 对于有序集合,返回带分数的结果
zMembers, err := c.client.ZRangeWithScores(ctx, key, 0, -1).Result()
if err != nil {
return nil, err
}
// 转换为 map 格式,便于展示
result := make([]map[string]interface{}, len(zMembers))
for i, member := range zMembers {
result[i] = map[string]interface{}{
"member": member.Member,
"score": member.Score,
}
}
return result, nil
case "hash":
return c.client.HGetAll(ctx, key).Result()
default:
return nil, fmt.Errorf("不支持的类型: %s", keyType)
}
}
// GetTTL 获取 Key 的 TTL
func (c *RedisClient) GetTTL(ctx context.Context, key string) (time.Duration, error) {
return c.client.TTL(ctx, key).Result()
}
// GetKeyInfo 获取 Key 详细信息
func (c *RedisClient) GetKeyInfo(ctx context.Context, key string) (map[string]interface{}, error) {
info := map[string]interface{}{
"key": key,
"type": "",
"value": nil,
"ttl": 0,
}
// 获取 Key 类型
keyType, err := c.GetKeyType(ctx, key)
if err != nil {
return nil, fmt.Errorf("获取 Key 类型失败: %v", err)
}
info["type"] = keyType
// 获取 TTL
ttl, err := c.GetTTL(ctx, key)
if err != nil {
return nil, fmt.Errorf("获取 TTL 失败: %v", err)
}
info["ttl"] = ttl.Seconds()
// 获取 Key 值(限制大小,避免过大)
value, err := c.GetKeyValue(ctx, key)
if err != nil {
return nil, fmt.Errorf("获取 Key 值失败: %v", err)
}
info["value"] = formatValuePreview(value)
// 获取 Key 长度(使用 STRLEN、HLEN、SCARD、ZCARD
var keyLength int64
switch keyType {
case "string":
keyLength, err = c.client.StrLen(ctx, key).Result()
case "list":
keyLength, err = c.client.LLen(ctx, key).Result()
case "set":
keyLength, err = c.client.SCard(ctx, key).Result()
case "zset":
keyLength, err = c.client.ZCard(ctx, key).Result()
case "hash":
keyLength, err = c.client.HLen(ctx, key).Result()
}
if err == nil {
info["length"] = keyLength
}
return info, nil
}
// formatValuePreview 格式化值预览(限制长度)
func formatValuePreview(value interface{}) string {
if value == nil {
return ""
}
const maxPreviewLength = 200
valueStr := fmt.Sprintf("%v", value)
if len(valueStr) > maxPreviewLength {
valueStr = valueStr[:maxPreviewLength] + "..."
}
return valueStr
}
// ListDatabases 获取数据库列表Redis 使用 DB number
// Redis 没有传统数据库概念,这里返回空数组
func (c *RedisClient) ListDatabases(ctx context.Context) ([]string, error) {
// Redis 可以使用 DB number 来隔离数据
// 这里可以返回当前配置的 DB 或者所有可用的 DB
// 为简单起见,返回空数组,让用户直接操作 Key
return []string{}, nil
}

View File

@@ -0,0 +1,187 @@
package service
import (
"encoding/json"
"fmt"
"go-desk/internal/crypto"
"go-desk/internal/dbclient"
"go-desk/internal/storage/models"
"go-desk/internal/storage/repository"
)
// ConnectionService 连接管理服务
type ConnectionService struct {
repo repository.ConnectionRepository
}
// NewConnectionService 创建连接服务
func NewConnectionService() (*ConnectionService, error) {
repo, err := repository.NewConnectionRepository()
if err != nil {
return nil, fmt.Errorf("创建连接仓库失败: %v", err)
}
return &ConnectionService{repo: repo}, nil
}
// SaveConnection 保存连接配置
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
// 验证
if conn.Name == "" {
return fmt.Errorf("连接名称不能为空")
}
if conn.Type == "" {
return fmt.Errorf("数据库类型不能为空")
}
if conn.Host == "" {
return fmt.Errorf("主机地址不能为空")
}
// 检查名称是否重复
existing, err := s.repo.FindByName(conn.Name, conn.ID)
if err != nil {
return fmt.Errorf("检查连接名称失败: %v", err)
}
if existing != nil {
return fmt.Errorf("连接名称已存在")
}
// 处理密码
if conn.ID > 0 {
if conn.Password == "" {
// 更新模式:保留原密码
conn.Password, err = s.getPassword(conn.ID)
if err != nil {
return err
}
} else {
// 加密新密码
conn.Password, err = crypto.EncryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码加密失败: %v", err)
}
}
} else {
// 新增模式:加密密码
conn.Password, err = crypto.EncryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码加密失败: %v", err)
}
}
return s.repo.Save(conn)
}
// getPassword 获取原始密码
func (s *ConnectionService) getPassword(id uint) (string, error) {
existing, err := s.repo.FindByID(id)
if err != nil {
return "", fmt.Errorf("获取原连接配置失败: %v", err)
}
return existing.Password, nil
}
// ListConnections 获取连接列表
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
return s.repo.FindAll()
}
// GetConnection 获取连接详情
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
return s.repo.FindByID(id)
}
// DeleteConnection 删除连接配置
func (s *ConnectionService) DeleteConnection(id uint) error {
return s.repo.Delete(id)
}
// TestConnection 测试连接通过已保存的连接ID
func (s *ConnectionService) TestConnection(id uint) error {
conn, err := s.repo.FindByID(id)
if err != nil {
return fmt.Errorf("获取连接配置失败: %v", err)
}
// 解密密码用于测试
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码解密失败: %v", err)
}
// 根据类型测试连接
switch conn.Type {
case "mysql":
return dbclient.TestMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
case "redis":
return dbclient.TestRedisConnection(conn.Host, conn.Port, password)
case "mongo":
// 解析 Options 获取 MongoDB 连接参数
authSource := ""
authMechanism := ""
if conn.Options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
if as, ok := opts["authSource"].(string); ok && as != "" {
authSource = as
}
if am, ok := opts["authMechanism"].(string); ok && am != "" {
authMechanism = am
}
}
}
return dbclient.TestMongoConnectionWithOptions(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
default:
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// TestConnectionWithParams 测试连接(直接传入参数,不保存数据)
func (s *ConnectionService) TestConnectionWithParams(connType, host string, port int, username, password, database, options string, existingId uint) error {
// 验证必填项
if connType == "" {
return fmt.Errorf("数据库类型不能为空")
}
if host == "" {
return fmt.Errorf("主机地址不能为空")
}
// 如果是编辑模式且密码为空,尝试获取已保存的密码
actualPassword := password
if existingId > 0 && password == "" {
conn, err := s.repo.FindByID(existingId)
if err != nil {
return fmt.Errorf("获取原连接配置失败: %v", err)
}
// 解密原密码
actualPassword, err = crypto.DecryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码解密失败: %v", err)
}
}
// 根据类型测试连接
switch connType {
case "mysql":
return dbclient.TestMySQLConnection(host, port, username, actualPassword, database)
case "redis":
return dbclient.TestRedisConnection(host, port, actualPassword)
case "mongo":
// 解析 Options 获取 MongoDB 连接参数
authSource := ""
authMechanism := ""
if options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(options), &opts); err == nil {
if as, ok := opts["authSource"].(string); ok && as != "" {
authSource = as
}
if am, ok := opts["authMechanism"].(string); ok && am != "" {
authMechanism = am
}
}
}
return dbclient.TestMongoConnectionWithOptions(host, port, username, actualPassword, database, authSource, authMechanism)
default:
return fmt.Errorf("不支持的数据库类型: %s", connType)
}
}

View File

@@ -0,0 +1,467 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"go-desk/internal/dbclient"
"go-desk/internal/storage/models"
"go-desk/internal/storage/repository"
)
// SqlExecService SQL执行服务
type SqlExecService struct {
connRepo repository.ConnectionRepository
pool *dbclient.ConnectionPool
}
// NewSqlExecService 创建SQL执行服务
func NewSqlExecService() (*SqlExecService, error) {
connRepo, err := repository.NewConnectionRepository()
if err != nil {
return nil, err
}
return &SqlExecService{
connRepo: connRepo,
pool: dbclient.GetPool(),
}, nil
}
// SqlResult SQL执行结果
type SqlResult struct {
Type string `json:"type"` // query/update/command
Data interface{} `json:"data"` // 查询结果数据
Columns []string `json:"columns"` // 列顺序(仅查询时有效)
RowsAffected int `json:"rowsAffected"` // 影响行数
ExecutionTime int64 `json:"executionTime"` // 执行时间(毫秒)
}
// ExecuteSQL 执行SQL语句
// 注意SQL 语句应该已经包含分页信息LIMIT 和 OFFSET由客户端添加
func (s *SqlExecService) ExecuteSQL(connectionID uint, sqlStr string, database string) (*SqlResult, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
return s.executeMySQL(ctx, conn, sqlStr, database, startTime)
case "redis":
return s.executeRedis(ctx, conn, sqlStr, startTime)
case "mongo":
return s.executeMongo(ctx, conn, sqlStr, database, startTime)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// executeMySQL 执行MySQL SQL
func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
sqlStr = strings.TrimSpace(sqlStr)
sqlUpper := strings.ToUpper(sqlStr)
// 获取数据库参数
dbName := database
if dbName == "" {
dbName = conn.Database
}
result := &SqlResult{
ExecutionTime: time.Since(startTime).Milliseconds(),
}
// 判断是查询还是更新
if strings.HasPrefix(sqlUpper, "SELECT") || strings.HasPrefix(sqlUpper, "SHOW") ||
strings.HasPrefix(sqlUpper, "DESCRIBE") || strings.HasPrefix(sqlUpper, "DESC") ||
strings.HasPrefix(sqlUpper, "EXPLAIN") {
// 查询语句
queryResult, err := client.ExecuteQuery(ctx, sqlStr, dbName)
if err != nil {
return nil, err
}
result.Type = "query"
result.Data = queryResult.Data
result.Columns = queryResult.Columns
result.RowsAffected = len(queryResult.Data)
} else {
// 更新语句
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, dbName)
if err != nil {
return nil, err
}
result.Type = "update"
result.RowsAffected = int(rowsAffected)
result.Data = nil
}
return result, nil
}
// executeRedis 执行Redis命令
func (s *SqlExecService) executeRedis(ctx context.Context, conn *models.DbConnection, sqlStr string, startTime time.Time) (*SqlResult, error) {
client, err := s.pool.GetRedisClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
// 解析Redis命令
parts := parseRedisCommand(sqlStr)
if len(parts) == 0 {
return nil, fmt.Errorf("Redis 命令不能为空")
}
cmd := strings.ToUpper(parts[0])
args := make([]interface{}, 0)
for i := 1; i < len(parts); i++ {
args = append(args, parts[i])
}
data, err := client.ExecuteCommand(ctx, cmd, args...)
if err != nil {
return nil, err
}
return &SqlResult{
Type: "command",
Data: data,
RowsAffected: 1,
ExecutionTime: time.Since(startTime).Milliseconds(),
}, nil
}
// executeMongo 执行MongoDB命令
func (s *SqlExecService) executeMongo(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
// 解析MongoDB命令JSON格式
var command map[string]interface{}
sqlStr = strings.TrimSpace(sqlStr)
if err := json.Unmarshal([]byte(sqlStr), &command); err != nil {
return nil, fmt.Errorf("MongoDB 命令必须是有效的 JSON 格式: %v", err)
}
// 确定数据库
dbName := conn.Database
if db, ok := command["database"].(string); ok && db != "" {
dbName = db
}
if database != "" {
dbName = database
}
if dbName == "" {
return nil, fmt.Errorf("需要指定数据库名称")
}
// 执行命令
data, err := client.ExecuteCommand(ctx, dbName, command)
if err != nil {
return nil, err
}
result := &SqlResult{
Type: "command",
Data: data,
ExecutionTime: time.Since(startTime).Milliseconds(),
}
// 根据操作类型确定影响行数
if op, ok := command["op"].(string); ok {
switch op {
case "find":
if results, ok := data.([]map[string]interface{}); ok {
result.RowsAffected = len(results)
}
case "count":
if count, ok := data.(int64); ok {
result.RowsAffected = int(count)
}
case "insertOne", "deleteOne":
result.RowsAffected = 1
case "insertMany":
if resultMap, ok := data.(map[string]interface{}); ok {
if count, ok := resultMap["insertedCount"].(int); ok {
result.RowsAffected = count
}
}
default:
result.RowsAffected = 0
}
}
return result, nil
}
// GetDatabases 获取数据库列表
func (s *SqlExecService) GetDatabases(connectionID uint) ([]string, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
return client.ListDatabases(ctx)
case "redis":
databases := make([]string, 16)
for i := 0; i < 16; i++ {
databases[i] = fmt.Sprintf("%d", i)
}
return databases, nil
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
return client.ListDatabases(ctx)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// GetTables 获取表列表MySQL/MongoDB或Key列表Redis
func (s *SqlExecService) GetTables(connectionID uint, database string) ([]string, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
return client.ListTables(ctx, database)
case "redis":
client, err := s.pool.GetRedisClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
return client.GetKeys(ctx, database)
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
return client.ListCollections(ctx, database)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// parseRedisCommand 解析Redis命令
func parseRedisCommand(cmd string) []string {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return []string{}
}
var parts []string
var current strings.Builder
inQuotes := false
quoteChar := byte(0)
for i := 0; i < len(cmd); i++ {
char := cmd[i]
if !inQuotes {
if char == '"' || char == '\'' {
inQuotes = true
quoteChar = char
} else if char == ' ' || char == '\t' {
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
} else {
current.WriteByte(char)
}
} else {
if char == quoteChar {
inQuotes = false
quoteChar = 0
} else {
current.WriteByte(char)
}
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// GetTableStructure 获取表结构
func (s *SqlExecService) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
structure, err := client.GetTableStructure(ctx, database, tableName)
if err != nil {
return nil, err
}
return map[string]interface{}{
"type": "mysql",
"database": database,
"table": tableName,
"columns": structure,
}, nil
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
structure, err := client.GetCollectionStructure(ctx, database, tableName)
if err != nil {
return nil, err
}
return map[string]interface{}{
"type": "mongo",
"database": database,
"collection": tableName,
"structure": structure,
}, nil
case "redis":
client, err := s.pool.GetRedisClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
info, err := client.GetKeyInfo(ctx, tableName)
if err != nil {
return nil, err
}
return map[string]interface{}{
"type": "redis",
"key": tableName,
"info": info,
}, nil
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// GetIndexes 获取索引列表
func (s *SqlExecService) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
return client.GetIndexes(ctx, database, tableName)
case "mongo", "redis":
return []map[string]interface{}{}, nil
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// PreviewTableStructure 预览表结构变更
func (s *SqlExecService) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
return client.PreviewTableStructure(ctx, database, tableName, structure)
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
return client.PreviewCollectionIndexes(ctx, database, tableName, structure)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// UpdateTableStructure 更新表结构
func (s *SqlExecService) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
conn, err := s.connRepo.FindByID(connectionID)
if err != nil {
return nil, fmt.Errorf("获取连接配置失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
}
return client.UpdateTableStructure(ctx, database, tableName, structure)
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
return client.UpdateCollectionIndexes(ctx, database, tableName, structure)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}

View File

@@ -0,0 +1,36 @@
package service
import (
"fmt"
"go-desk/internal/storage/models"
"go-desk/internal/storage/repository"
)
// TabService 标签页管理服务
type TabService struct {
repo repository.TabRepository
}
// NewTabService 创建标签页服务
func NewTabService() (*TabService, error) {
repo, err := repository.NewTabRepository()
if err != nil {
return nil, fmt.Errorf("创建标签页仓库失败: %v", err)
}
return &TabService{repo: repo}, nil
}
// SaveTabs 保存标签页列表
func (s *TabService) SaveTabs(tabs []models.SqlTab) error {
return s.repo.SaveAll(tabs)
}
// ListTabs 获取标签页列表
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
return s.repo.FindAll()
}
// DeleteTab 删除标签页
func (s *TabService) DeleteTab(id uint) error {
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,165 @@
package storage
import (
"encoding/json"
"fmt"
"go-desk/internal/crypto"
"go-desk/internal/dbclient"
"go-desk/internal/storage/models"
"gorm.io/gorm"
)
// ConnectionService 连接管理服务
type ConnectionService struct {
db *gorm.DB
}
// NewConnectionService 创建连接服务
func NewConnectionService() (*ConnectionService, error) {
db := GetDB()
if db == nil {
// 尝试重新初始化
var err error
db, err = Init()
if err != nil {
return nil, fmt.Errorf("数据库初始化失败: %v", err)
}
}
return &ConnectionService{db: db}, nil
}
// SaveConnection 保存连接配置
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
if conn.Name == "" {
return fmt.Errorf("连接名称不能为空")
}
if conn.Type == "" {
return fmt.Errorf("数据库类型不能为空")
}
if conn.Host == "" {
return fmt.Errorf("主机地址不能为空")
}
// 检查名称是否重复(排除当前记录)
var count int64
query := s.db.Model(&models.DbConnection{}).Where("name = ?", conn.Name)
if conn.ID > 0 {
query = query.Where("id != ?", conn.ID)
}
query.Count(&count)
if count > 0 {
return fmt.Errorf("连接名称已存在")
}
if conn.ID > 0 {
// 更新模式
updateData := map[string]interface{}{
"name": conn.Name,
"type": conn.Type,
"host": conn.Host,
"port": conn.Port,
"username": conn.Username,
"database": conn.Database,
"options": conn.Options,
}
// 如果提供了新密码,加密后更新
if conn.Password != "" {
encrypted, err := crypto.EncryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码加密失败: %v", err)
}
updateData["password"] = encrypted
}
// 如果密码为空,不更新密码字段(保留原密码)
return s.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(updateData).Error
}
// 新增模式 - 必须提供密码
if conn.Password == "" {
return fmt.Errorf("新增连接时密码不能为空")
}
// 加密密码
encrypted, err := crypto.EncryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码加密失败: %v", err)
}
conn.Password = encrypted
return s.db.Create(conn).Error
}
// ListConnections 获取连接列表
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
var connections []models.DbConnection
err := s.db.Order("created_at DESC").Find(&connections).Error
return connections, err
}
// GetConnection 获取连接详情
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
var conn models.DbConnection
err := s.db.First(&conn, id).Error
if err != nil {
return nil, err
}
return &conn, nil
}
// DeleteConnection 删除连接配置
func (s *ConnectionService) DeleteConnection(id uint) error {
return s.db.Delete(&models.DbConnection{}, id).Error
}
// TestConnection 测试连接(需要根据类型调用不同的测试方法)
func (s *ConnectionService) TestConnection(conn *models.DbConnection) error {
// 解密密码用于测试
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return fmt.Errorf("密码解密失败: %v", err)
}
// 根据类型测试连接
switch conn.Type {
case "mysql":
return testMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
case "redis":
return testRedisConnection(conn.Host, conn.Port, password)
case "mongo":
// 解析 Options 获取 MongoDB 连接参数
authSource := ""
authMechanism := ""
if conn.Options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
if as, ok := opts["authSource"].(string); ok && as != "" {
authSource = as
}
if am, ok := opts["authMechanism"].(string); ok && am != "" {
authMechanism = am
}
}
}
return testMongoConnection(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
default:
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}
// testMySQLConnection 测试 MySQL 连接
func testMySQLConnection(host string, port int, username, password, database string) error {
return dbclient.TestMySQLConnection(host, port, username, password, database)
}
// testRedisConnection 测试 Redis 连接
func testRedisConnection(host string, port int, password string) error {
return dbclient.TestRedisConnection(host, port, password)
}
// testMongoConnection 测试 MongoDB 连接
func testMongoConnection(host string, port int, username, password, database, authSource, authMechanism string) error {
return dbclient.TestMongoConnectionWithOptions(host, port, username, password, database, authSource, authMechanism)
}

View File

@@ -0,0 +1,25 @@
package models
import (
"time"
)
// DbConnection 数据库连接配置
type DbConnection struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
Port int `gorm:"not null" json:"port"` // 端口
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名MySQL/MongoDB
Options string `gorm:"type:text" json:"options"` // 额外选项JSON格式
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名
func (DbConnection) TableName() string {
return "db_connection"
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
)
// SqlFile SQL 文件记录
type SqlFile struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(200);not null" json:"name"` // 文件名
Path string `gorm:"type:varchar(500);not null;uniqueIndex" json:"path"` // 文件路径
Content string `gorm:"type:text" json:"content"` // 文件内容
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名
func (SqlFile) TableName() string {
return "sql_file"
}

View File

@@ -0,0 +1,24 @@
package models
import (
"time"
)
// SqlResultHistory SQL 执行结果历史
type SqlResultHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
ConnectionID uint `gorm:"index;not null" json:"connection_id"` // 连接ID
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名
Sql string `gorm:"type:text;not null" json:"sql"` // SQL语句
Type string `gorm:"type:varchar(20);not null" json:"type"` // 结果类型: query/update/command
Data string `gorm:"type:text" json:"data"` // 结果数据(JSON)
Columns string `gorm:"type:text" json:"columns"` // 列信息(JSON)
RowsAffected int `gorm:"default:0" json:"rows_affected"` // 影响行数
ExecutionTime int64 `gorm:"default:0" json:"execution_time"` // 执行时间(毫秒)
CreatedAt time.Time `json:"created_at"`
}
// TableName 指定表名
func (SqlResultHistory) TableName() string {
return "sql_result_history"
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
)
// SqlTab SQL 编辑器标签页
type SqlTab struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"type:varchar(100);not null" json:"title"` // 标签页标题
Content string `gorm:"type:text" json:"content"` // SQL 内容
ConnectionID *uint `gorm:"index" json:"connection_id"` // 关联的连接ID可为空
Order int `gorm:"default:0" json:"order"` // 排序顺序
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名
func (SqlTab) TableName() string {
return "sql_tab"
}

View File

@@ -0,0 +1,70 @@
package repository
import (
"go-desk/internal/storage"
"go-desk/internal/storage/models"
"gorm.io/gorm"
)
type ConnectionRepository interface {
Save(conn *models.DbConnection) error
FindAll() ([]models.DbConnection, error)
FindByID(id uint) (*models.DbConnection, error)
Delete(id uint) error
FindByName(name string, excludeID uint) (*models.DbConnection, error)
}
type connectionRepository struct {
db *gorm.DB
}
func NewConnectionRepository() (ConnectionRepository, error) {
db := storage.GetDB()
if db == nil {
var err error
db, err = storage.Init()
if err != nil {
return nil, err
}
}
return &connectionRepository{db}, nil
}
func (r *connectionRepository) Save(conn *models.DbConnection) error {
if conn.ID > 0 {
return r.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(conn).Error
}
return r.db.Create(conn).Error
}
func (r *connectionRepository) FindAll() ([]models.DbConnection, error) {
var connections []models.DbConnection
return connections, r.db.Order("created_at DESC").Find(&connections).Error
}
func (r *connectionRepository) FindByID(id uint) (*models.DbConnection, error) {
var conn models.DbConnection
err := r.db.First(&conn, id).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &conn, err
}
func (r *connectionRepository) Delete(id uint) error {
return r.db.Delete(&models.DbConnection{}, id).Error
}
func (r *connectionRepository) FindByName(name string, excludeID uint) (*models.DbConnection, error) {
var conn models.DbConnection
query := r.db.Where("name = ?", name)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
err := query.First(&conn).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &conn, err
}

View File

@@ -0,0 +1,110 @@
package repository
import (
"encoding/json"
"go-desk/internal/storage"
"go-desk/internal/storage/models"
"gorm.io/gorm"
"time"
)
type ResultRepository interface {
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
FindByID(id uint) (*models.SqlResultHistory, error)
FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error)
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
Delete(id uint) error
DeleteByConnection(connectionID uint) error
DeleteOld(keepDays int) error
}
type resultRepository struct {
db *gorm.DB
}
func NewResultRepository() (ResultRepository, error) {
db := storage.GetDB()
if db == nil {
var err error
db, err = storage.Init()
if err != nil {
return nil, err
}
}
return &resultRepository{db}, nil
}
func (r *resultRepository) Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error) {
dataJSON, _ := json.Marshal(data)
columnsJSON, _ := json.Marshal(columns)
history := &models.SqlResultHistory{
ConnectionID: connectionID,
Database: database,
Sql: sql,
Type: resultType,
Data: string(dataJSON),
Columns: string(columnsJSON),
RowsAffected: rowsAffected,
ExecutionTime: executionTime,
}
return history, r.db.Create(history).Error
}
func (r *resultRepository) FindByID(id uint) (*models.SqlResultHistory, error) {
var history models.SqlResultHistory
err := r.db.First(&history, id).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &history, err
}
func (r *resultRepository) FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error) {
var histories []models.SqlResultHistory
query := r.db.Where("connection_id = ?", connectionID).Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
return histories, query.Find(&histories).Error
}
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
query := r.db.Model(&models.SqlResultHistory{})
if connectionID != nil {
query = query.Where("connection_id = ?", *connectionID)
}
if keyword != "" {
query = query.Where("sql LIKE ? OR database LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var histories []models.SqlResultHistory
query = query.Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
if offset > 0 {
query = query.Offset(offset)
}
return histories, total, query.Find(&histories).Error
}
func (r *resultRepository) Delete(id uint) error {
return r.db.Delete(&models.SqlResultHistory{}, id).Error
}
func (r *resultRepository) DeleteByConnection(connectionID uint) error {
return r.db.Where("connection_id = ?", connectionID).Delete(&models.SqlResultHistory{}).Error
}
func (r *resultRepository) DeleteOld(keepDays int) error {
return r.db.Where("created_at < ?", time.Now().AddDate(0, 0, -keepDays)).Delete(&models.SqlResultHistory{}).Error
}

View File

@@ -0,0 +1,55 @@
package repository
import (
"go-desk/internal/storage"
"go-desk/internal/storage/models"
"gorm.io/gorm"
)
type TabRepository interface {
SaveAll(tabs []models.SqlTab) error
FindAll() ([]models.SqlTab, error)
Delete(id uint) error
DeleteAll() error
}
type tabRepository struct {
db *gorm.DB
}
func NewTabRepository() (TabRepository, error) {
db := storage.GetDB()
if db == nil {
var err error
db, err = storage.Init()
if err != nil {
return nil, err
}
}
return &tabRepository{db}, nil
}
func (r *tabRepository) SaveAll(tabs []models.SqlTab) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1=1").Delete(&models.SqlTab{}).Error; err != nil {
return err
}
if len(tabs) > 0 {
return tx.Create(&tabs).Error
}
return nil
})
}
func (r *tabRepository) FindAll() ([]models.SqlTab, error) {
var tabs []models.SqlTab
return tabs, r.db.Order("`order` ASC, created_at ASC").Find(&tabs).Error
}
func (r *tabRepository) Delete(id uint) error {
return r.db.Delete(&models.SqlTab{}, id).Error
}
func (r *tabRepository) DeleteAll() error {
return r.db.Where("1=1").Delete(&models.SqlTab{}).Error
}

View File

@@ -0,0 +1,57 @@
package storage
import (
"go-desk/internal/storage/models"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var globalDB *gorm.DB
func Init() (*gorm.DB, error) {
if globalDB != nil {
return globalDB, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dataDir := filepath.Join(homeDir, ".go-desk")
os.MkdirAll(dataDir, 0755)
dbPath := filepath.Join(dataDir, "db-cli.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, err
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour)
if err := db.AutoMigrate(
&models.DbConnection{},
&models.SqlTab{},
&models.SqlResultHistory{},
); err != nil {
return nil, err
}
globalDB = db
return globalDB, nil
}
func GetDB() *gorm.DB {
return globalDB
}

29
main.go
View File

@@ -2,8 +2,10 @@ package main
import ( import (
"embed" "embed"
"runtime"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
) )
@@ -15,11 +17,32 @@ func main() {
// 创建应用实例 // 创建应用实例
app := NewApp() app := NewApp()
// 创建应用菜单
appMenu := menu.NewMenu()
// 添加"视图"菜单
viewMenu := appMenu.AddSubmenu("视图")
viewMenu.AddText("重新加载", nil, func(_ *menu.CallbackData) {
app.Reload()
})
viewMenu.AddSeparator()
viewMenu.AddText("清理缓存", nil, func(_ *menu.CallbackData) {
app.ClearCache()
})
// 在 macOS 上添加默认的"编辑"菜单
if runtime.GOOS == "darwin" {
appMenu.Append(menu.EditMenu())
}
// 创建应用配置 // 创建应用配置
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "Go Desk", Title: "Go Desk - 数据库客户端",
Width: 1200, Width: 1400,
Height: 800, Height: 900,
MinWidth: 1000,
MinHeight: 600,
Menu: appMenu,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },

779
web/package-lock.json generated
View File

@@ -9,11 +9,17 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@arco-design/web-vue": "^2.54.0", "@arco-design/web-vue": "^2.54.0",
"vue": "^3.4.0" "@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.8",
"vue": "^3.5.26"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^6.0.3",
"vite": "^5.0.0" "vite": "^7.3.0"
} }
}, },
"node_modules/@arco-design/color": { "node_modules/@arco-design/color": {
@@ -79,8 +85,534 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.1",
"resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.1.tgz",
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.3",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
"integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.8",
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.39.8.tgz",
"integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.21.5", "version": "0.27.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -91,13 +623,61 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": ">=12" "node": ">=18"
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/common": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.5",
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.5.tgz",
"integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0", "version": "4.54.0",
"cpu": [ "cpu": [
@@ -128,14 +708,19 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "6.0.3",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
"integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.53"
},
"engines": { "engines": {
"node": "^18.0.0 || >=20.0.0" "node": "^20.19.0 || >=22.12.0"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
@@ -260,6 +845,12 @@
"version": "1.0.20", "version": "1.0.20",
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"license": "MIT" "license": "MIT"
@@ -279,7 +870,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.27.2",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -287,38 +880,74 @@
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5", "@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.21.5", "@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.21.5", "@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.21.5", "@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.21.5", "@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.21.5", "@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.21.5", "@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-x64": "0.21.5", "@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.21.5", "@esbuild/netbsd-x64": "0.27.2",
"@esbuild/sunos-x64": "0.21.5", "@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/win32-arm64": "0.21.5", "@esbuild/openbsd-x64": "0.27.2",
"@esbuild/win32-ia32": "0.21.5", "@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/win32-x64": "0.21.5" "@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.3.4", "version": "0.3.4",
"license": "MIT" "license": "MIT"
@@ -354,6 +983,20 @@
"version": "1.1.1", "version": "1.1.1",
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"funding": [ "funding": [
@@ -445,21 +1088,49 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "7.3.0",
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.27.0",
"postcss": "^8.4.43", "fdir": "^6.5.0",
"rollup": "^4.20.0" "picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
}, },
"engines": { "engines": {
"node": "^18.0.0 || >=20.0.0" "node": "^20.19.0 || >=22.12.0"
}, },
"funding": { "funding": {
"url": "https://github.com/vitejs/vite?sponsor=1" "url": "https://github.com/vitejs/vite?sponsor=1"
@@ -468,19 +1139,25 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^20.19.0 || >=22.12.0",
"less": "*", "jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0", "lightningcss": "^1.21.0",
"sass": "*", "sass": "^1.70.0",
"sass-embedded": "*", "sass-embedded": "^1.70.0",
"stylus": "*", "stylus": ">=0.54.8",
"sugarss": "*", "sugarss": "^5.0.0",
"terser": "^5.4.0" "terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/node": { "@types/node": {
"optional": true "optional": true
}, },
"jiti": {
"optional": true
},
"less": { "less": {
"optional": true "optional": true
}, },
@@ -501,6 +1178,12 @@
}, },
"terser": { "terser": {
"optional": true "optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
} }
} }
}, },
@@ -523,6 +1206,12 @@
"optional": true "optional": true
} }
} }
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
} }
} }
} }

View File

@@ -8,12 +8,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0", "@arco-design/web-vue": "^2.54.0",
"@arco-design/web-vue": "^2.54.0" "@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.8",
"vue": "^3.5.26"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^6.0.3",
"vite": "^5.0.0" "vite": "^7.3.0"
} }
} }

View File

@@ -1 +1 @@
0e83a53f44aeb269f56998d3dfad9991 1fcf61f2f95666be3cda4149328a0c09

View File

@@ -4,12 +4,19 @@
<div class="header-content"> <div class="header-content">
<h2>Go Desk</h2> <h2>Go Desk</h2>
<a-tabs v-model:active-key="activeTab" class="header-tabs"> <a-tabs v-model:active-key="activeTab" class="header-tabs">
<a-tab-pane key="user" title="用户查询" /> <a-tab-pane key="db-cli" title="数据库客户端"/>
<a-tab-pane key="device" title="设备调用测试" /> <a-tab-pane key="user" title="用户查询"/>
<a-tab-pane key="device" title="设备调用测试"/>
</a-tabs> </a-tabs>
<div class="header-actions">
<ThemeToggle />
</div>
</div> </div>
</a-layout-header> </a-layout-header>
<a-layout-content class="content"> <a-layout-content class="content">
<!-- 数据库客户端 -->
<DbCli v-if="activeTab === 'db-cli'"/>
<!-- 用户查询页面 --> <!-- 用户查询页面 -->
<div v-if="activeTab === 'user'"> <div v-if="activeTab === 'user'">
<!-- 查询表单 --> <!-- 查询表单 -->
@@ -17,17 +24,17 @@
<a-form :model="formModel" layout="inline"> <a-form :model="formModel" layout="inline">
<a-form-item label="关键字"> <a-form-item label="关键字">
<a-input <a-input
v-model="formModel.keyword" v-model="formModel.keyword"
placeholder="姓名、账号、电话" placeholder="姓名、账号、电话"
allow-clear allow-clear
style="width: 200px" style="width: 200px"
/> />
</a-form-item> </a-form-item>
<a-form-item label="状态"> <a-form-item label="状态">
<a-select <a-select
v-model="formModel.status" v-model="formModel.status"
placeholder="选择状态" placeholder="选择状态"
style="width: 120px" style="width: 120px"
> >
<a-option :value="0">全部</a-option> <a-option :value="0">全部</a-option>
<a-option :value="1">正常</a-option> <a-option :value="1">正常</a-option>
@@ -39,13 +46,13 @@
<a-space> <a-space>
<a-button type="primary" @click="handleSearch"> <a-button type="primary" @click="handleSearch">
<template #icon> <template #icon>
<icon-search /> <icon-search/>
</template> </template>
查询 查询
</a-button> </a-button>
<a-button @click="handleReset"> <a-button @click="handleReset">
<template #icon> <template #icon>
<icon-refresh /> <icon-refresh/>
</template> </template>
重置 重置
</a-button> </a-button>
@@ -57,12 +64,12 @@
<!-- 数据表格 --> <!-- 数据表格 -->
<a-card class="table-card"> <a-card class="table-card">
<a-table <a-table
:columns="columns" :columns="columns"
:data="tableData" :data="tableData"
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="pagination"
@page-change="handlePageChange" @page-change="handlePageChange"
@page-size-change="handlePageSizeChange" @page-size-change="handlePageSizeChange"
> >
<template #status="{ record }"> <template #status="{ record }">
<a-tag v-if="record.status === 1" color="green">正常</a-tag> <a-tag v-if="record.status === 1" color="green">正常</a-tag>
@@ -74,17 +81,19 @@
</div> </div>
<!-- 设备调用测试页面 --> <!-- 设备调用测试页面 -->
<DeviceTest v-if="activeTab === 'device'" /> <DeviceTest v-if="activeTab === 'device'"/>
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import {onMounted, ref} from 'vue'
import { Message } from '@arco-design/web-vue' import {Message} from '@arco-design/web-vue'
import DeviceTest from './components/DeviceTest.vue' import DeviceTest from './components/DeviceTest.vue'
import DbCli from './views/db-cli/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
const activeTab = ref('user') const activeTab = ref('db-cli')
const loading = ref(false) const loading = ref(false)
const formModel = ref({ const formModel = ref({
keyword: '', keyword: '',
@@ -102,14 +111,14 @@ const pagination = ref({
}) })
const columns = [ const columns = [
{ title: '编号', dataIndex: 'memberid', width: 80 }, {title: '编号', dataIndex: 'memberid', width: 80},
{ title: '姓名', dataIndex: 'membername', width: 120 }, {title: '姓名', dataIndex: 'membername', width: 120},
{ title: '账号', dataIndex: 'account', width: 150 }, {title: '账号', dataIndex: 'account', width: 150},
{ title: '联系电话', dataIndex: 'contactphone', width: 130 }, {title: '联系电话', dataIndex: 'contactphone', width: 130},
{ title: '机构ID', dataIndex: 'organid', width: 100 }, {title: '机构ID', dataIndex: 'organid', width: 100},
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 80 }, {title: '状态', dataIndex: 'status', slotName: 'status', width: 80},
{ title: '创建时间', dataIndex: 'createtime', width: 180 }, {title: '创建时间', dataIndex: 'createtime', width: 180},
{ title: '修改时间', dataIndex: 'updatetime', width: 180 } {title: '修改时间', dataIndex: 'updatetime', width: 180}
] ]
const loadData = async () => { const loadData = async () => {
@@ -122,14 +131,14 @@ const loadData = async () => {
loading.value = true loading.value = true
try { try {
const result = await window.go.main.App.QueryUsers( const result = await window.go.main.App.QueryUsers(
formModel.value.keyword || '', formModel.value.keyword || '',
formModel.value.status || 0, formModel.value.status || 0,
formModel.value.role || 0, formModel.value.role || 0,
formModel.value.organid || 0, formModel.value.organid || 0,
pagination.value.current, pagination.value.current,
pagination.value.pageSize, pagination.value.pageSize,
'createtime', 'createtime',
'descend' 'descend'
) )
if (result && result.rows) { if (result && result.rows) {
@@ -200,6 +209,7 @@ onMounted(() => {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
color: var(--color-text-1);
} }
.header-tabs { .header-tabs {
@@ -207,6 +217,12 @@ onMounted(() => {
margin-left: 40px; margin-left: 40px;
} }
.header-actions {
display: flex;
align-items: center;
margin-left: 20px;
}
.content { .content {
padding: 20px; padding: 20px;
flex: 1; flex: 1;

25
web/src/api/connection.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 连接相关 API
*/
import type { Connection } from './types'
/**
* 获取连接列表
*/
export async function listConnections(): Promise<Connection[]> {
if (!window.go?.main?.App?.ListDbConnections) {
throw new Error('ListDbConnections API 不可用')
}
return await window.go.main.App.ListDbConnections()
}
/**
* 删除连接
*/
export async function deleteConnection(id: number): Promise<void> {
if (!window.go?.main?.App?.DeleteDbConnection) {
throw new Error('DeleteDbConnection API 不可用')
}
await window.go.main.App.DeleteDbConnection(id)
}

25
web/src/api/database.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 数据库和表相关 API
*/
import type { Database, Table } from './types'
/**
* 获取数据库列表
*/
export async function getDatabases(connectionId: number): Promise<Database[]> {
if (!window.go?.main?.App?.GetDatabases) {
throw new Error('GetDatabases API 不可用')
}
return await window.go.main.App.GetDatabases(connectionId)
}
/**
* 获取表列表
*/
export async function getTables(connectionId: number, database: string): Promise<Table[]> {
if (!window.go?.main?.App?.GetTables) {
throw new Error('GetTables API 不可用')
}
return await window.go.main.App.GetTables(connectionId, database)
}

11
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* API 统一导出
*/
export * from './types'
export * from './connection'
export * from './database'
export * from './structure'
export * from './query'
export * from './tab'
export * from './system'

21
web/src/api/query.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* SQL 查询相关 API
*/
import type { QueryResult } from './types'
/**
* 执行 SQL 查询
*/
export async function executeQuery(
connectionId: number,
sql: string,
database?: string,
page?: number,
pageSize?: number
): Promise<QueryResult> {
if (!window.go?.main?.App?.ExecuteSQL) {
throw new Error('ExecuteSQL API 不可用')
}
return await window.go.main.App.ExecuteSQL(connectionId, sql, database, page, pageSize)
}

19
web/src/api/structure.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 表结构相关 API
*/
import type { Structure } from './types'
/**
* 获取表结构
*/
export async function getTableStructure(
connectionId: number,
database: string,
table: string
): Promise<Structure> {
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('GetTableStructure API 不可用')
}
return await window.go.main.App.GetTableStructure(connectionId, database, table)
}

95
web/src/api/system.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* 系统信息相关 API
*/
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
/**
* 获取系统信息
*/
export async function getSystemInfo(): Promise<SystemInfo> {
if (!window.go?.main?.App?.GetSystemInfo) {
throw new Error('GetSystemInfo API 不可用')
}
return await window.go.main.App.GetSystemInfo()
}
/**
* 获取 CPU 信息
*/
export async function getCPUInfo(): Promise<CPU> {
if (!window.go?.main?.App?.GetCPUInfo) {
throw new Error('GetCPUInfo API 不可用')
}
return await window.go.main.App.GetCPUInfo()
}
/**
* 获取内存信息
*/
export async function getMemoryInfo(): Promise<Memory> {
if (!window.go?.main?.App?.GetMemoryInfo) {
throw new Error('GetMemoryInfo API 不可用')
}
return await window.go.main.App.GetMemoryInfo()
}
/**
* 获取磁盘信息
*/
export async function getDiskInfo(): Promise<Disk> {
if (!window.go?.main?.App?.GetDiskInfo) {
throw new Error('GetDiskInfo API 不可用')
}
return await window.go.main.App.GetDiskInfo()
}
/**
* 列出目录文件
*/
export async function listDir(path: string): Promise<File[]> {
if (!window.go?.main?.App?.ListDir) {
throw new Error('ListDir API 不可用')
}
return await window.go.main.App.ListDir(path)
}
/**
* 读取文件
*/
export async function readFile(path: string): Promise<string> {
if (!window.go?.main?.App?.ReadFile) {
throw new Error('ReadFile API 不可用')
}
return await window.go.main.App.ReadFile(path)
}
/**
* 写入文件
*/
export async function writeFile(path: string, content: string): Promise<void> {
if (!window.go?.main?.App?.WriteFile) {
throw new Error('WriteFile API 不可用')
}
await window.go.main.App.WriteFile(path, content)
}
/**
* 删除文件或目录
*/
export async function deletePath(path: string): Promise<void> {
if (!window.go?.main?.App?.DeletePath) {
throw new Error('DeletePath API 不可用')
}
await window.go.main.App.DeletePath(path)
}
/**
* 获取环境变量
*/
export async function getEnvVars(): Promise<Record<string, string>> {
if (!window.go?.main?.App?.GetEnvVars) {
throw new Error('GetEnvVars API 不可用')
}
return await window.go.main.App.GetEnvVars()
}

25
web/src/api/tab.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 标签页相关 API
*/
import type { Tab } from './types'
/**
* 保存标签页
*/
export async function saveTabs(tabs: Tab[]): Promise<void> {
if (!window.go?.main?.App?.SaveSqlTabs) {
throw new Error('SaveSqlTabs API 不可用')
}
await window.go.main.App.SaveSqlTabs(tabs)
}
/**
* 获取标签页列表
*/
export async function listTabs(): Promise<Tab[]> {
if (!window.go?.main?.App?.ListSqlTabs) {
throw new Error('ListSqlTabs API 不可用')
}
return await window.go.main.App.ListSqlTabs()
}

108
web/src/api/types.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* API 类型定义
*/
// 连接
export interface Connection {
id: number
name: string
dbType: string
host: string
port: number
username: string
database?: string
createdAt?: string
}
// 数据库和表
export interface Database {
name: string
tableCount?: number
}
export interface Table {
name: string
type?: string
}
// 表结构
export interface Column {
Field: string
Type: string
Null: string
Key: string
Default: string | null
Comment: string
Extra?: string
}
export interface Index {
Key_name: string
Column_name: string
Non_unique: number
Seq_in_index: number
Index_type: string
}
export interface Structure {
database: string
table: string
type: 'mysql' | 'mongo' | 'redis'
columns?: Column[]
indexes?: Index[]
structure?: any
info?: any
}
// SQL 查询
export interface QueryResult {
columns: string[]
data: any[]
rowsAffected: number
executionTime: number
}
// 标签页
export interface Tab {
id?: number
title: string
content: string
connectionId?: number | null
order?: number
}
// 系统信息
export interface SystemInfo {
os: string
arch: string
version: string
}
export interface CPU {
model: string
cores: number
usage: number
}
export interface Memory {
total: number
used: number
free: number
usage: number
}
export interface Disk {
path: string
total: number
used: number
free: number
usage: number
}
export interface File {
name: string
path: string
size: number
isDir: boolean
modified?: string
}

View File

@@ -40,10 +40,10 @@
</a-row> </a-row>
<a-card size="small" title="磁盘信息" v-if="diskInfo && diskInfo.length > 0"> <a-card size="small" title="磁盘信息" v-if="diskInfo && diskInfo.length > 0">
<a-table <a-table
:columns="diskColumns" :columns="diskColumns"
:data="diskInfo" :data="diskInfo"
:pagination="false" :pagination="false"
size="small" size="small"
/> />
</a-card> </a-card>
</a-space> </a-space>
@@ -54,9 +54,9 @@
<a-space direction="vertical" :size="16" style="width: 100%"> <a-space direction="vertical" :size="16" style="width: 100%">
<a-input-group> <a-input-group>
<a-input <a-input
v-model="filePath" v-model="filePath"
placeholder="输入文件或目录路径" placeholder="输入文件或目录路径"
style="flex: 1" style="flex: 1"
/> />
<a-button @click="browseDirectory">浏览</a-button> <a-button @click="browseDirectory">浏览</a-button>
<a-button type="primary" @click="listDirectory">列出目录</a-button> <a-button type="primary" @click="listDirectory">列出目录</a-button>
@@ -65,9 +65,9 @@
<a-col :span="12"> <a-col :span="12">
<a-card size="small" title="文件列表"> <a-card size="small" title="文件列表">
<a-list <a-list
:data="fileList" :data="fileList"
:loading="fileLoading" :loading="fileLoading"
style="max-height: 300px; overflow-y: auto" style="max-height: 300px; overflow-y: auto"
> >
<template #item="{ item }"> <template #item="{ item }">
<a-list-item> <a-list-item>
@@ -92,9 +92,9 @@
<a-card size="small" title="文件内容"> <a-card size="small" title="文件内容">
<a-space direction="vertical" :size="8" style="width: 100%"> <a-space direction="vertical" :size="8" style="width: 100%">
<a-textarea <a-textarea
v-model="fileContent" v-model="fileContent"
:rows="10" :rows="10"
placeholder="文件内容将显示在这里" placeholder="文件内容将显示在这里"
/> />
<a-space> <a-space>
<a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button> <a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button>
@@ -112,20 +112,31 @@
<a-card class="test-card" title="环境变量"> <a-card class="test-card" title="环境变量">
<a-button @click="loadEnvVars" :loading="envLoading">加载环境变量</a-button> <a-button @click="loadEnvVars" :loading="envLoading">加载环境变量</a-button>
<a-table <a-table
v-if="envVars" v-if="envVars"
:columns="envColumns" :columns="envColumns"
:data="envTableData" :data="envTableData"
:pagination="{ pageSize: 20 }" :pagination="{ pageSize: 20 }"
style="margin-top: 16px" style="margin-top: 16px"
size="small" size="small"
/> />
</a-card> </a-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import {computed, onMounted, ref} from 'vue'
import { Message } from '@arco-design/web-vue' import {Message} from '@arco-design/web-vue'
import {
getSystemInfo,
getCPUInfo,
getMemoryInfo,
getDiskInfo,
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath,
getEnvVars
} from '@/api'
const systemInfo = ref(null) const systemInfo = ref(null)
const cpuInfo = ref(null) const cpuInfo = ref(null)
@@ -139,17 +150,17 @@ const envVars = ref(null)
const envLoading = ref(false) const envLoading = ref(false)
const diskColumns = [ const diskColumns = [
{ title: '设备', dataIndex: 'device', width: 120 }, {title: '设备', dataIndex: 'device', width: 120},
{ title: '挂载点', dataIndex: 'mountpoint', width: 200 }, {title: '挂载点', dataIndex: 'mountpoint', width: 200},
{ title: '总容量', dataIndex: 'total_str', width: 100 }, {title: '总容量', dataIndex: 'total_str', width: 100},
{ title: '已用', dataIndex: 'used_str', width: 100 }, {title: '已用', dataIndex: 'used_str', width: 100},
{ title: '可用', dataIndex: 'free_str', width: 100 }, {title: '可用', dataIndex: 'free_str', width: 100},
{ title: '使用率', dataIndex: 'usage', width: 80 } {title: '使用率', dataIndex: 'usage', width: 80}
] ]
const envColumns = [ const envColumns = [
{ title: '变量名', dataIndex: 'key', width: 200 }, {title: '变量名', dataIndex: 'key', width: 200},
{ title: '值', dataIndex: 'value' } {title: '值', dataIndex: 'value'}
] ]
const envTableData = computed(() => { const envTableData = computed(() => {
@@ -162,12 +173,10 @@ const envTableData = computed(() => {
const refreshSystemInfo = async () => { const refreshSystemInfo = async () => {
try { try {
if (window.go?.main?.App) { systemInfo.value = await getSystemInfo()
systemInfo.value = await window.go.main.App.GetSystemInfo() cpuInfo.value = await getCPUInfo()
cpuInfo.value = await window.go.main.App.GetCPUInfo() memoryInfo.value = await getMemoryInfo()
memoryInfo.value = await window.go.main.App.GetMemoryInfo() diskInfo.value = await getDiskInfo()
diskInfo.value = await window.go.main.App.GetDiskInfo()
}
} catch (error) { } catch (error) {
console.error('获取系统信息失败:', error) console.error('获取系统信息失败:', error)
Message.error('获取系统信息失败: ' + (error.message || error)) Message.error('获取系统信息失败: ' + (error.message || error))
@@ -181,9 +190,7 @@ const listDirectory = async () => {
} }
fileLoading.value = true fileLoading.value = true
try { try {
if (window.go?.main?.App) { fileList.value = await listDir(filePath.value)
fileList.value = await window.go.main.App.ListDir(filePath.value)
}
} catch (error) { } catch (error) {
console.error('列出目录失败:', error) console.error('列出目录失败:', error)
Message.error('列出目录失败: ' + (error.message || error)) Message.error('列出目录失败: ' + (error.message || error))
@@ -199,9 +206,7 @@ const readFile = async () => {
} }
fileLoading.value = true fileLoading.value = true
try { try {
if (window.go?.main?.App) { fileContent.value = await readFileApi(filePath.value)
fileContent.value = await window.go.main.App.ReadFile(filePath.value)
}
} catch (error) { } catch (error) {
console.error('读取文件失败:', error) console.error('读取文件失败:', error)
Message.error('读取文件失败: ' + (error.message || error)) Message.error('读取文件失败: ' + (error.message || error))
@@ -217,10 +222,8 @@ const writeFile = async () => {
} }
fileLoading.value = true fileLoading.value = true
try { try {
if (window.go?.main?.App) { await writeFileApi(filePath.value, fileContent.value)
await window.go.main.App.WriteFile(filePath.value, fileContent.value) Message.success('文件写入成功')
Message.success('文件写入成功')
}
} catch (error) { } catch (error) {
console.error('写入文件失败:', error) console.error('写入文件失败:', error)
Message.error('写入文件失败: ' + (error.message || error)) Message.error('写入文件失败: ' + (error.message || error))
@@ -239,13 +242,11 @@ const deleteFile = async () => {
} }
fileLoading.value = true fileLoading.value = true
try { try {
if (window.go?.main?.App) { await deletePath(filePath.value)
await window.go.main.App.DeletePath(filePath.value) Message.success('删除成功')
Message.success('删除成功') filePath.value = ''
filePath.value = '' fileContent.value = ''
fileContent.value = '' fileList.value = []
fileList.value = []
}
} catch (error) { } catch (error) {
console.error('删除失败:', error) console.error('删除失败:', error)
Message.error('删除失败: ' + (error.message || error)) Message.error('删除失败: ' + (error.message || error))
@@ -269,9 +270,7 @@ const browseDirectory = () => {
const loadEnvVars = async () => { const loadEnvVars = async () => {
envLoading.value = true envLoading.value = true
try { try {
if (window.go?.main?.App) { envVars.value = await getEnvVars()
envVars.value = await window.go.main.App.GetEnvVars()
}
} catch (error) { } catch (error) {
console.error('加载环境变量失败:', error) console.error('加载环境变量失败:', error)
Message.error('加载环境变量失败: ' + (error.message || error)) Message.error('加载环境变量失败: ' + (error.message || error))

View File

@@ -0,0 +1,50 @@
<template>
<a-tooltip :content="tooltipText" position="bottom">
<div
class="theme-toggle-btn"
@click="handleToggle"
>
{{ isDark ? '🌙' : '☀️' }}
</div>
</a-tooltip>
</template>
<script setup>
import { computed } from 'vue'
import { useTheme } from '../composables/useTheme'
const { isDark, toggleTheme } = useTheme()
const tooltipText = computed(() => {
return isDark.value ? '切换到亮色主题' : '切换到夜间主题'
})
const handleToggle = () => {
toggleTheme()
}
</script>
<style scoped>
.theme-toggle-btn {
width: 32px;
height: 32px;
font-size: 16px;
cursor: pointer;
border-radius: var(--border-radius-small, 2px);
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
transition: background-color 0.2s;
}
.theme-toggle-btn:hover {
background: var(--color-bg-2);
}
.theme-toggle-btn:active {
background: var(--color-bg-3);
}
</style>

View File

@@ -0,0 +1,8 @@
/**
* 全局 Composables 导出
*/
export * from './useLocalStorage'
export * from './useDebounce'
export * from './useTablePage'
export * from './useApiError'

View File

@@ -0,0 +1,61 @@
/**
* API Error handling composable
* 统一的 API 错误处理
*/
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
export interface ApiErrorState {
hasError: boolean
message: string
code?: string | number
}
export function useApiError() {
const error = ref<ApiErrorState>({
hasError: false,
message: ''
})
const setError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
let message = defaultMessage
let code: string | number | undefined
if (err instanceof Error) {
message = err.message || defaultMessage
} else if (typeof err === 'string') {
message = err
} else if (err && typeof err === 'object' && 'message' in err) {
message = (err as any).message || defaultMessage
if ('code' in err) code = (err as any).code
}
error.value = {
hasError: true,
message,
code
}
return { message, code }
}
const showError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
const { message } = setError(err, defaultMessage)
Message.error(message)
}
const clearError = () => {
error.value = {
hasError: false,
message: ''
}
}
return {
error,
setError,
showError,
clearError
}
}

View File

@@ -0,0 +1,34 @@
/**
* Debounce composable
* 防抖函数
*/
import { ref, watch, type Ref, type ComputedRef } from 'vue'
export function useDebounce<T>(value: Ref<T> | ComputedRef<T>, delay: number = 300): Ref<T> {
const debouncedValue = ref<T>(value.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout> | null = null
watch(value, (newValue) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
export function debounceFn<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
fn(...args)
}, delay)
}
}

View File

@@ -0,0 +1,34 @@
/**
* LocalStorage composable
* 通用的 localStorage 操作
*/
import { watch, type Ref } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T,
storage: Storage = localStorage
): [Ref<T>, (value: T) => void, () => void] {
const stored = storage.getItem(key)
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
const setValue = (newValue: T) => {
value.value = newValue
}
const clearValue = () => {
value.value = defaultValue
storage.removeItem(key)
}
watch(value, (newValue) => {
try {
storage.setItem(key, JSON.stringify(newValue))
} catch (e) {
console.warn(`Failed to save ${key} to localStorage:`, e)
}
}, { deep: true })
return [value, setValue, clearValue]
}

View File

@@ -0,0 +1,60 @@
/**
* Table Pagination composable
* 表格分页逻辑
*/
import { ref, computed } from 'vue'
export interface PaginationOptions {
pageSize?: number
initialPage?: number
}
export function useTablePage(options: PaginationOptions = {}) {
const { pageSize = 10, initialPage = 1 } = options
const currentPage = ref(initialPage)
const currentPageSize = ref(pageSize)
const canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value * currentPageSize.value < totalItems.value)
const totalItems = ref(0)
const totalPages = computed(() => Math.ceil(totalItems.value / currentPageSize.value))
const nextPage = () => {
if (canGoNext.value) currentPage.value++
}
const prevPage = () => {
if (canGoPrev.value) currentPage.value--
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
const reset = () => {
currentPage.value = initialPage
}
const setTotalItems = (total: number) => {
totalItems.value = total
}
return {
currentPage,
currentPageSize,
canGoPrev,
canGoNext,
totalItems,
totalPages,
nextPage,
prevPage,
goToPage,
reset,
setTotalItems
}
}

View File

@@ -0,0 +1,78 @@
import { ref, computed } from 'vue'
type Theme = 'light' | 'dark'
const THEME_STORAGE_KEY = 'app-theme'
// 单例模式:全局共享主题状态
const theme = ref<Theme>('light')
let systemThemeListener: (() => void) | null = null
// 应用主题到 DOM
const applyTheme = (newTheme: Theme) => {
theme.value = newTheme
if (newTheme === 'dark') {
document.body.setAttribute('arco-theme', 'dark')
} else {
document.body.removeAttribute('arco-theme')
}
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
}
// 初始化主题(只调用一次)
const initTheme = () => {
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
applyTheme(savedTheme)
} else {
// 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
applyTheme('dark')
} else {
applyTheme('light')
}
}
// 监听系统主题变化
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
// 如果用户没有手动设置过主题,则跟随系统
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
applyTheme(e.matches ? 'dark' : 'light')
}
}
mediaQuery.addEventListener('change', handleChange)
systemThemeListener = () => mediaQuery.removeEventListener('change', handleChange)
}
}
export function useTheme() {
// 切换主题
const toggleTheme = () => {
const newTheme: Theme = theme.value === 'light' ? 'dark' : 'light'
applyTheme(newTheme)
}
// 设置为亮色主题
const setLightTheme = () => {
applyTheme('light')
}
// 设置为暗色主题
const setDarkTheme = () => {
applyTheme('dark')
}
return {
theme: computed(() => theme.value),
isDark: computed(() => theme.value === 'dark'),
toggleTheme,
setLightTheme,
setDarkTheme,
initTheme
}
}
// 导出初始化函数(在 main.js 中使用)
export { initTheme }

View File

@@ -1,10 +1,15 @@
import { createApp } from 'vue' import {createApp} from 'vue'
import ArcoVue from '@arco-design/web-vue' import ArcoVue from '@arco-design/web-vue'
import '@arco-design/web-vue/dist/arco.css' import '@arco-design/web-vue/dist/arco.css'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import {initTheme} from './composables/useTheme'
const app = createApp(App) const app = createApp(App)
app.use(ArcoVue) app.use(ArcoVue)
// 在应用挂载前初始化主题
initTheme()
app.mount('#app') app.mount('#app')

View File

@@ -1,17 +1,51 @@
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
#app { #app {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
} }
/* 滚动条样式优化 */
/* Webkit浏览器 (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-2, rgba(0, 0, 0, 0.1));
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-3, rgba(0, 0, 0, 0.2));
background-clip: padding-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
}

22
web/src/types/window.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* 全局 Window 类型声明
* 扩展 Window 接口以支持 Wails 的 window.go
*
* 注意:这是过渡方案,最终应该使用 Wails 生成的包装函数App.js
* 而不是直接访问 window.go.main.App
*/
import type * as App from '../wailsjs/wailsjs/go/main/App'
declare global {
interface Window {
go: {
main: {
App: typeof App
}
}
}
}
export {}

View File

@@ -0,0 +1,511 @@
<template>
<a-modal
v-model:visible="visible"
title="数据库连接配置"
width="560px"
:body-style="{ padding: '16px 20px' }"
@cancel="handleCancel"
>
<!-- 错误提示区域 -->
<a-alert
v-if="errorMessage"
type="error"
show-icon
closable
@close="errorMessage = ''"
class="error-alert"
>
{{ errorMessage }}
</a-alert>
<a-form :model="form" :rules="rules" ref="formRef" layout="horizontal" :label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }" size="small">
<a-form-item label="连接名称" field="name">
<a-input v-model="form.name" placeholder="请输入连接名称" size="small"/>
</a-form-item>
<a-form-item label="数据库类型" field="type">
<a-select v-model="form.type" placeholder="请选择数据库类型" @change="handleTypeChange" size="small">
<a-option value="mysql">MySQL</a-option>
<a-option value="redis">Redis</a-option>
<a-option value="mongo">MongoDB</a-option>
</a-select>
</a-form-item>
<a-form-item label="主机地址" field="host">
<a-input v-model="form.host" placeholder="请输入主机地址" size="small"/>
</a-form-item>
<a-form-item label="端口" field="port">
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="请输入端口" style="width: 100%"
size="small"/>
</a-form-item>
<a-form-item label="用户名" field="username" v-if="form.type !== 'redis'">
<a-input v-model="form.username" placeholder="请输入用户名" size="small"/>
</a-form-item>
<a-form-item label="密码" field="password">
<div v-if="props.connectionId && !isPasswordChanged" class="password-display">
<a-input
value="已保存的密码"
disabled
class="password-input"
size="small"
/>
<a-button type="text" size="mini" @click="isPasswordChanged = true">
修改密码
</a-button>
</div>
<a-input-password
v-else
v-model="form.password"
:placeholder="getPasswordPlaceholder()"
size="small"
/>
</a-form-item>
<a-form-item :label="form.type === 'redis' ? '数据库编号' : '数据库名'" field="database">
<a-input v-model="form.database"
:placeholder="form.type === 'redis' ? 'Redis DB 编号 (0-15默认为0)' : '可选,留空则连接所有数据库'"
:max-length="100"
size="small"/>
</a-form-item>
<!-- MongoDB 专用选项 -->
<template v-if="form.type === 'mongo'">
<a-form-item label="认证数据库" field="options.authSource">
<a-input v-model="optionsForm.authSource" placeholder="留空则使用 admin" size="small"/>
<template #extra>
<span class="form-item-extra">MongoDB 用户所在的数据库通常为 admin可选</span>
</template>
</a-form-item>
</template>
</a-form>
<template #footer>
<a-space size="small">
<a-button @click="handleTest" :loading="testing" size="small">测试连接</a-button>
<a-button @click="handleCancel" size="small">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="saving" size="small">保存</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup lang="ts">
import {reactive, ref, watch} from 'vue'
import {Message} from '@arco-design/web-vue'
import {
ListDbConnections,
SaveDbConnection
} from '../../../wailsjs/wailsjs/go/main/App'
// 使用 defineModel 简化 v-model:visible 双向绑定Vue 3.5+
const visible = defineModel('visible', { type: Boolean, default: false })
// 使用 TypeScript 泛型语法Vue 3.5+
const props = defineProps<{
connectionId?: number | null
}>()
const emit = defineEmits<{
success: []
}>()
const formRef = ref<any>(null)
const saving = ref(false)
const testing = ref(false)
const errorMessage = ref('')
// 是否修改密码(编辑模式下)
const isPasswordChanged = ref(false)
const form = reactive({
name: '',
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: '',
password: '',
database: '',
options: ''
})
// 选项表单(用于表单输入)
const optionsForm = reactive({
authSource: ''
})
// 将 options JSON 字符串解析为 optionsForm
const parseOptionsToForm = (optionsStr: string) => {
if (!optionsStr || optionsStr.trim() === '') {
optionsForm.authSource = ''
return
}
try {
const opts = JSON.parse(optionsStr)
optionsForm.authSource = opts.authSource || ''
// 认证机制使用自动检测,不需要从选项读取
} catch (error) {
console.warn('解析 Options JSON 失败:', error)
// 解析失败时,清空表单选项
optionsForm.authSource = ''
}
}
// 将 optionsForm 和 form.options 合并为 JSON 字符串
const mergeOptionsToJson = (): string => {
let customOptions: any = {}
// 先解析已有的 JSON可能包含其他自定义选项
if (form.options && form.options.trim() !== '') {
try {
customOptions = JSON.parse(form.options)
} catch (error) {
console.warn('解析自定义 Options JSON 失败:', error)
}
}
// 根据数据库类型合并表单选项(仅 MongoDB
if (form.type === 'mongo') {
// 只有认证数据库不为空时才添加
if (optionsForm.authSource && optionsForm.authSource.trim() !== '') {
customOptions.authSource = optionsForm.authSource.trim()
}
// 认证机制使用自动检测,不需要添加到选项
}
// 如果没有任何选项,返回空字符串
if (Object.keys(customOptions).length === 0) {
return ''
}
return JSON.stringify(customOptions)
}
// 表单验证规则
const rules = {
name: [
{required: true, message: '请输入连接名称'},
{maxLength: 100, message: '连接名称长度不能超过100个字符'}
],
type: [{required: true, message: '请选择数据库类型'}],
host: [
{required: true, message: '请输入主机地址'},
{maxLength: 255, message: '主机地址长度不能超过255个字符'}
],
port: [
{required: true, message: '请输入端口'},
{
validator: (value, callback) => {
if (!value || value < 1 || value > 65535) {
callback('端口号必须在1-65535之间')
} else {
callback()
}
}
}
],
database: [
{
validator: (value, callback) => {
// MySQL 类型时数据库名为可选(允许为空)
// MongoDB 和 Redis 也为可选
callback()
}
}
]
}
// 获取密码输入框的占位符
const getPasswordPlaceholder = () => {
if (props.connectionId) {
return '请输入新密码'
}
switch (form.type) {
case 'redis':
return '可选,留空则无密码连接'
case 'mongo':
return '可选,留空则无认证连接'
default:
return '请输入密码'
}
}
// 监听类型变化,设置默认端口、主机和用户名
const handleTypeChange = (type) => {
// 如果主机为空,设置默认值
if (!form.host || form.host.trim() === '') {
form.host = '127.0.0.1'
}
// 根据类型设置默认端口和用户名
switch (type) {
case 'mysql':
form.port = 3306
if (!form.username) {
form.username = 'root'
}
// 清空 MongoDB 专用选项
optionsForm.authSource = ''
form.options = ''
break
case 'redis':
form.port = 6379
form.username = '' // Redis 不需要用户名
if (!form.database) {
form.database = '0' // Redis 默认 DB 0
}
// 清空其他数据库的选项
optionsForm.authSource = ''
form.options = ''
break
case 'mongo':
case 'mongodb':
form.port = 27017
if (!form.username) {
form.username = 'admin' // MongoDB 常用默认用户名
}
break
}
// 类型变化时,同步更新 options JSON
form.options = mergeOptionsToJson()
}
// 加载连接详情
const loadConnection = async () => {
if (!props.connectionId) {
resetForm()
return
}
isLoading.value = true
try {
if (!(window as any).go?.main?.App?.ListDbConnections) {
return
}
const connections = await (window as any).go.main.App.ListDbConnections()
const conn = connections.find(c => c.id === props.connectionId)
if (conn) {
form.name = conn.name
form.type = conn.type
form.host = conn.host || '127.0.0.1'
form.port = conn.port || (conn.type === 'mysql' ? 3306 : conn.type === 'redis' ? 6379 : 27017)
form.username = conn.username || ''
form.database = conn.database || ''
// 先解析 options 到表单
parseOptionsToForm(conn.options || '')
// 然后设置 form.options这样不会触发 watch
form.options = conn.options || ''
// 编辑模式下,默认不修改密码
form.password = ''
isPasswordChanged.value = false
}
} catch (error) {
console.error('加载连接详情失败:', error)
} finally {
isLoading.value = false
}
}
// 重置表单
const resetForm = () => {
form.name = ''
form.type = 'mysql'
form.host = '127.0.0.1'
form.port = 3306
form.username = 'root' // MySQL 默认用户名
form.password = ''
form.database = ''
form.options = ''
optionsForm.authSource = ''
isPasswordChanged.value = false
}
// 测试连接(不保存数据)
const handleTest = async () => {
if (!formRef.value) {
console.error('formRef 未初始化')
return
}
// 清除之前的错误信息
errorMessage.value = ''
try {
await formRef.value.validate()
// 验证通过,继续执行
} catch (error) {
// 表单验证失败
const errorFields = error?.fields || {}
const firstError = Object.values(errorFields)[0]
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
errorMessage.value = errorMsg
Message.warning(errorMsg)
return
}
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
Message.error('Go 后端未就绪,请确保应用已启动')
return
}
testing.value = true
try {
// 编辑模式下,如果未修改密码,传递空字符串(后端会获取已保存的密码)
const passwordToTest = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
// 合并选项为 JSON
const optionsJson = mergeOptionsToJson()
// 直接测试连接,不保存数据
await (window as any).go.main.App.TestDbConnectionWithParams({
id: props.connectionId || 0, // 编辑模式下传递ID用于获取已保存的密码
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: passwordToTest,
database: form.database || '',
options: optionsJson
})
Message.success('连接测试成功')
errorMessage.value = ''
} catch (error) {
console.error('连接测试失败:', error)
const errorMsg = error.message || error.toString() || '未知错误'
errorMessage.value = '连接测试失败: ' + errorMsg
Message.error('连接测试失败: ' + errorMsg)
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) {
console.error('formRef 未初始化')
return
}
// 清除之前的错误信息
errorMessage.value = ''
try {
await formRef.value.validate()
// 验证通过,继续执行
} catch (error) {
// 表单验证失败
const errorFields = error?.fields || {}
const firstError = Object.values(errorFields)[0]
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
errorMessage.value = errorMsg
Message.warning(errorMsg)
return
}
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
Message.error('Go 后端未就绪,请确保应用已启动')
return
}
saving.value = true
try {
// 编辑模式下,如果未修改密码,传递空字符串(后端会保留原密码)
const passwordToSave = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
// 合并选项为 JSON
const optionsJson = mergeOptionsToJson()
await (window as any).go.main.App.SaveDbConnection({
id: props.connectionId || 0,
name: form.name,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: passwordToSave,
database: form.database || '',
options: optionsJson
})
Message.success(props.connectionId ? '更新成功' : '保存成功')
errorMessage.value = ''
emit('success')
visible.value = false
} catch (error) {
console.error('保存失败:', error)
const errorMsg = error.message || error.toString() || '未知错误'
errorMessage.value = '保存失败: ' + errorMsg
Message.error('保存失败: ' + errorMsg)
} finally {
saving.value = false
}
}
// 取消
const handleCancel = () => {
errorMessage.value = ''
visible.value = false
resetForm()
}
// 是否正在加载连接(用于避免加载时触发 watch
const isLoading = ref(false)
// 监听 optionsForm 变化,自动同步到 form.options仅 MongoDB
watch(
() => [optionsForm.authSource, form.type],
() => {
// 如果正在加载,不触发更新
if (isLoading.value) {
return
}
// 仅 MongoDB 需要同步选项
if (visible.value && form.type === 'mongo') {
form.options = mergeOptionsToJson()
}
},
{ deep: true }
)
// 监听 visible 变化
watch(visible, (val) => {
if (val) {
errorMessage.value = ''
loadConnection()
} else {
errorMessage.value = ''
resetForm()
}
})
</script>
<style scoped>
.error-alert {
margin-bottom: 12px;
}
.password-display {
display: flex;
align-items: center;
gap: 8px;
}
.password-input {
flex: 1;
}
.options-item {
margin-bottom: 8px;
}
.form-item-extra {
font-size: 12px;
color: var(--color-text-3);
margin-top: 4px;
display: block;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="context-menu-overlay"
@click="handleOverlayClick"
@contextmenu.prevent="handleOverlayClick"
>
<div
class="context-menu"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
@click.stop
>
<template v-for="(item, index) in processedItems" :key="item.key || index">
<div
v-if="item.divider"
class="context-menu-divider"
></div>
<div
v-else
class="context-menu-item"
:class="{ disabled: item.disabled }"
@click="handleMenuItemClick(item)"
>
<span v-if="item.icon" class="context-menu-item-icon">
<component :is="item.icon"/>
</span>
<span class="context-menu-item-label">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
/**
* 菜单项配置
*/
export interface MenuItem {
key: string
label: string
icon?: Component
disabled?: boolean
divider?: boolean
handler?: () => void
}
/**
* 使用 defineModel 简化 v-model:visible 双向绑定Vue 3.5+
*/
const visible = defineModel<boolean>('visible', { default: false })
/**
* Props
*/
const props = defineProps<{
position: { x: number; y: number }
items: MenuItem[]
}>()
/**
* Emits
*/
const emit = defineEmits<{
'menu-item-click': [item: MenuItem]
}>()
/**
* 点击遮罩层关闭菜单
*/
const handleOverlayClick = () => {
visible.value = false
}
/**
* 处理菜单项点击
*/
const handleMenuItemClick = (item: MenuItem) => {
if (item.disabled) return
emit('menu-item-click', item)
if (item.handler) {
item.handler()
}
// 点击后关闭菜单
visible.value = false
}
/**
* 处理菜单项(处理分隔线)
* divider: true 表示在该菜单项之后添加分隔线
*/
const processedItems = computed(() => {
const result: MenuItem[] = []
props.items.forEach((item, index) => {
// 添加菜单项本身(不包含 divider 标记)
const menuItem = { ...item }
const hasDivider = menuItem.divider
delete menuItem.divider // 移除 divider 标记,避免在渲染时被当作分隔线
result.push(menuItem)
// 如果该项标记了 divider在其后添加分隔线
if (hasDivider) {
result.push({
key: `divider-${index}`,
label: '',
divider: true
})
}
})
return result
})
</script>
<style scoped>
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.context-menu {
position: fixed;
min-width: 160px;
padding: 4px 0;
background: var(--color-bg-popup, #fff);
border: 1px solid var(--color-border-2, #e5e6eb);
border-radius: var(--border-radius-medium, 4px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
z-index: 10000;
}
.context-menu-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
color: var(--color-text-1, #1d2129);
font-size: 14px;
transition: background-color 0.2s;
}
.context-menu-item:hover:not(.disabled) {
background: var(--color-fill-2, #f2f3f5);
}
.context-menu-item.disabled {
color: var(--color-text-4, #c9cdd4);
cursor: not-allowed;
}
.context-menu-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
font-size: 14px;
}
.context-menu-item-label {
flex: 1;
}
.context-menu-divider {
height: 1px;
margin: 4px 12px;
background: var(--color-border-2, #e5e6eb);
}
</style>

View File

@@ -0,0 +1,529 @@
<template>
<div class="mysql-create">
<a-tabs
v-model:active-key="activeTab"
type="line"
class="create-tabs"
>
<!-- 基本信息 -->
<a-tab-pane key="basic" title="基本信息">
<div class="tab-content basic-info-content">
<a-form :model="formData" layout="vertical" :label-col-props="{ span: 6 }">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据库" field="database">
<a-input v-model="formData.database" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="表名" field="tableName" :rules="[{ required: true, message: '请输入表名' }]">
<a-input v-model="formData.tableName" placeholder="请输入表名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="字符集" field="charset">
<a-select v-model="formData.charset" placeholder="选择字符集">
<a-option value="utf8mb4">utf8mb4</a-option>
<a-option value="utf8">utf8</a-option>
<a-option value="latin1">latin1</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序规则" field="collation">
<a-select v-model="formData.collation" placeholder="选择排序规则">
<a-option value="utf8mb4_general_ci">utf8mb4_general_ci</a-option>
<a-option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</a-option>
<a-option value="utf8_general_ci">utf8_general_ci</a-option>
<a-option value="utf8_unicode_ci">utf8_unicode_ci</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-tab-pane>
<!-- 字段列表 -->
<a-tab-pane key="fields" title="字段列表">
<div class="tab-content fields-content">
<MySQLFieldList
mode="create"
:fields="formData.fields"
@add-field="handleAddField"
@remove-field="handleRemoveField"
@move-field="handleMoveField"
@update-field="handleUpdateField"
/>
</div>
</a-tab-pane>
<!-- 索引列表 -->
<a-tab-pane key="indexes" title="索引列表">
<div class="tab-content indexes-content">
<div class="section-header">
<a-button type="primary" size="small" @click="showIndexDialog">
<template #icon>
<icon-plus />
</template>
添加索引
</a-button>
</div>
<div v-if="formData.indexes.length === 0" class="empty-tip">
<a-empty description="暂无索引(可选)" :image="false" />
</div>
<a-table
v-else
:columns="indexColumns"
:data="formData.indexes"
:pagination="false"
size="small"
:bordered="true"
>
<template #unique="{ record }">
<a-tag :color="record.unique ? 'blue' : 'default'">
{{ record.unique ? '是' : '否' }}
</a-tag>
</template>
<template #fields="{ record }">
{{ record.fields.map((f: any) => f.name).join(', ') }}
</template>
<template #operations="{ record, rowIndex }">
<a-button type="text" size="small" status="danger" @click="removeIndex(rowIndex)">
<template #icon>
<icon-delete />
</template>
</a-button>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- SQL预览 -->
<a-tab-pane key="sql" title="SQL预览">
<div class="tab-content sql-preview-content">
<div class="sql-preview-header">
<a-button type="text" size="small" @click="copySQL">
<template #icon>
<icon-copy />
</template>
复制
</a-button>
</div>
<div class="sql-preview-wrapper">
<pre class="sql-code">{{ sqlPreview }}</pre>
</div>
</div>
</a-tab-pane>
</a-tabs>
<!-- 索引定义对话框 -->
<a-modal
v-model:visible="indexDialogVisible"
title="添加索引"
:width="600"
@ok="handleIndexDialogOk"
@cancel="handleIndexDialogCancel"
>
<a-form :model="indexForm" layout="vertical" ref="indexFormRef">
<a-form-item label="索引名" field="name" :rules="[{ required: true, message: '请输入索引名' }]">
<a-input v-model="indexForm.name" placeholder="请输入索引名" />
</a-form-item>
<a-form-item label="唯一索引" field="unique">
<a-checkbox v-model="indexForm.unique">唯一索引</a-checkbox>
</a-form-item>
<a-form-item label="索引字段" field="fields" :rules="[{ required: true, message: '请至少选择一个字段' }]">
<a-select
v-model="indexForm.fields"
mode="multiple"
placeholder="选择索引字段"
:max-tag-count="3"
>
<a-option
v-for="field in formData.fields"
:key="field.name"
:value="field.name"
>
{{ field.name }} ({{ field.type }}{{ field.length ? `(${field.length})` : '' }})
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconCopy
} from '@arco-design/web-vue/es/icon'
import MySQLFieldList from './MySQLFieldList.vue'
// Props
interface Props {
connectionId: number
database: string
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'create', data: any): void
}>()
// 当前激活的 tab
const activeTab = ref<string>('basic')
// 表单数据
const formData = reactive({
database: props.database,
tableName: '',
charset: 'utf8mb4',
collation: 'utf8mb4_general_ci',
fields: [] as any[],
indexes: [] as any[]
})
// SQL 预览
const sqlPreview = computed(() => {
if (formData.fields.length === 0) {
return '-- 请先添加字段'
}
return generateSQL()
})
// 索引表格列
const indexColumns = [
{ title: '索引名', dataIndex: 'name', width: 150 },
{ title: '唯一', dataIndex: 'unique', slotName: 'unique', width: 80 },
{ title: '字段', slotName: 'fields', width: 200 },
{ title: '操作', slotName: 'operations', width: 100, fixed: 'right' }
]
// 索引对话框
const indexDialogVisible = ref(false)
const indexFormRef = ref()
const indexForm = reactive({
name: '',
unique: false,
fields: [] as string[]
})
// 字段列表事件处理
const handleAddField = (field: any) => {
formData.fields.push(field)
// 自动切换到字段列表 tab
if (activeTab.value !== 'fields') {
activeTab.value = 'fields'
}
}
const handleUpdateField = (index: number, field: string, value: any) => {
if (formData.fields[index]) {
formData.fields[index] = { ...formData.fields[index], [field]: value }
}
}
const handleRemoveField = (index: number) => {
formData.fields.splice(index, 1)
// 同时移除相关索引
formData.indexes = formData.indexes.filter((idx: any) => {
return idx.fields.every((f: any) => {
const fieldName = typeof f === 'string' ? f : f.name
return fieldName !== formData.fields[index]?.name
})
})
}
const handleMoveField = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index > 0) {
const temp = formData.fields[index]
formData.fields[index] = formData.fields[index - 1]
formData.fields[index - 1] = temp
} else if (direction === 'down' && index < formData.fields.length - 1) {
const temp = formData.fields[index]
formData.fields[index] = formData.fields[index + 1]
formData.fields[index + 1] = temp
}
}
const showIndexDialog = () => {
if (formData.fields.length === 0) {
Message.warning('请先添加字段')
return
}
// 重置表单
Object.assign(indexForm, {
name: '',
unique: false,
fields: []
})
indexDialogVisible.value = true
}
const handleIndexDialogOk = async () => {
try {
// 验证表单
await indexFormRef.value?.validate()
// 检查索引名是否重复
if (formData.indexes.some((idx: any) => idx.name === indexForm.name)) {
Message.error('索引名已存在')
return false // 阻止对话框关闭
}
// 添加索引(深拷贝避免引用问题)
const newIndex = {
name: indexForm.name,
unique: indexForm.unique,
fields: indexForm.fields.map((name: string) => ({ name, order: 'ASC' }))
}
formData.indexes.push(newIndex)
Message.success('索引添加成功')
indexDialogVisible.value = false
// 自动切换到索引列表 tab
if (activeTab.value !== 'indexes') {
activeTab.value = 'indexes'
}
} catch (error) {
// 表单验证失败时会抛出错误
console.error('索引表单验证失败:', error)
return false // 阻止对话框关闭
}
}
const handleIndexDialogCancel = () => {
indexDialogVisible.value = false
}
const removeIndex = (index: number) => {
formData.indexes.splice(index, 1)
}
// 复制 SQL
const copySQL = async () => {
try {
await navigator.clipboard.writeText(sqlPreview.value)
Message.success('SQL已复制到剪贴板')
} catch (error) {
Message.error('复制失败')
}
}
// 验证表单
const validate = (): boolean => {
if (!formData.tableName) {
Message.error('请输入表名')
return false
}
if (formData.fields.length === 0) {
Message.error('请至少添加一个字段')
return false
}
// 检查是否有主键
const hasPrimaryKey = formData.fields.some((f: any) => f.primaryKey)
if (!hasPrimaryKey) {
Message.warning('建议设置主键')
}
return true
}
// 转义 SQL 字符串(转义单引号)
const escapeSQLString = (str: string): string => {
return str.replace(/'/g, "''")
}
// 生成 SQL
const generateSQL = (): string => {
const fieldsSQL = formData.fields.map((field: any) => {
let sql = `\`${field.name}\` ${field.type}`
if (field.length) {
sql += `(${field.length})`
}
if (!field.nullable) {
sql += ' NOT NULL'
}
// 处理默认值
if (field.defaultValue !== null && field.defaultValue !== undefined) {
if (field.defaultValue === '') {
// 空字符串默认值
sql += ` DEFAULT ''`
} else {
// 转义单引号
const escapedDefault = escapeSQLString(String(field.defaultValue))
sql += ` DEFAULT '${escapedDefault}'`
}
}
if (field.autoIncrement) {
sql += ' AUTO_INCREMENT'
}
if (field.comment) {
// 转义注释中的单引号
const escapedComment = escapeSQLString(field.comment)
sql += ` COMMENT '${escapedComment}'`
}
return sql
}).join(',\n ')
// 主键
const primaryKeys = formData.fields.filter((f: any) => f.primaryKey).map((f: any) => `\`${f.name}\``)
let primaryKeySQL = ''
if (primaryKeys.length > 0) {
primaryKeySQL = `,\n PRIMARY KEY (${primaryKeys.join(', ')})`
}
// 索引
const indexesSQL = formData.indexes.map((idx: any) => {
const fields = idx.fields.map((f: any) => `\`${typeof f === 'string' ? f : f.name}\``).join(', ')
const unique = idx.unique ? 'UNIQUE ' : ''
return ` ${unique}KEY \`${idx.name}\` (${fields})`
}).join(',\n')
const sql = `CREATE TABLE \`${formData.database}\`.\`${formData.tableName}\` (
${fieldsSQL}${primaryKeySQL}${indexesSQL ? ',\n' + indexesSQL : ''}
) ENGINE=InnoDB DEFAULT CHARSET=${formData.charset} COLLATE=${formData.collation};`
return sql
}
// 暴露方法给父组件
defineExpose({
validate,
generateSQL,
getFormData: () => formData
})
</script>
<style scoped>
.mysql-create {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tabs 容器 */
.create-tabs {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs) {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs-content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs-content-list) {
height: 100%;
}
.create-tabs :deep(.arco-tabs-content-item) {
height: 100%;
overflow: hidden;
}
/* Tab 内容通用样式 */
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--spacing-md, 12px);
overflow: hidden;
min-height: 0;
}
/* 基本信息内容 */
.basic-info-content {
overflow-y: auto;
}
.basic-info-content :deep(.arco-form-item) {
margin-bottom: 16px;
}
/* 字段列表和索引列表内容 */
.fields-content,
.indexes-content {
overflow-y: auto;
}
.section-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.empty-tip {
padding: 20px;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.fields-content :deep(.arco-table),
.indexes-content :deep(.arco-table) {
margin-top: 12px;
}
/* SQL预览内容 */
.sql-preview-content {
overflow: hidden;
}
.sql-preview-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.sql-preview-wrapper {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--color-fill-2, #f2f3f5);
border: 1px solid var(--color-border-2, #e5e6eb);
border-radius: var(--border-radius-small, 2px);
padding: var(--spacing-md, 12px);
}
.sql-code {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-1, #1d2129);
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
}
</style>

View File

@@ -0,0 +1,446 @@
<template>
<div class="mysql-field-list">
<!-- 创建模式可编辑表格 + 添加按钮 -->
<template v-if="mode === 'create'">
<div class="section-header">
<a-button type="primary" size="small" @click="handleAddField">
<template #icon>
<icon-plus />
</template>
添加字段
</a-button>
</div>
<div v-if="fields.length === 0" class="empty-tip">
<a-empty description="暂无字段,请添加字段" :image="false" />
</div>
<a-table
v-else
:columns="createModeColumns"
:data="fields"
:pagination="false"
size="mini"
:bordered="true"
:scroll="{ x: 'max-content' }"
/>
</template>
<!-- 编辑模式可编辑表格 -->
<template v-else-if="mode === 'edit'">
<a-table
:columns="editModeColumns"
:data="fields"
:pagination="false"
size="mini"
:bordered="true"
:scroll="{ y: scrollHeight, x: 'max-content' }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconPlus,
IconUp,
IconDown,
IconDelete
} from '@arco-design/web-vue/es/icon'
import { Input, Select, Option, Optgroup, InputGroup, Checkbox, Button } from '@arco-design/web-vue'
import { mysqlDataTypeOptions, typesNeedLength, parseType, formatType } from '../utils/mysqlFieldUtils'
// Props
interface Props {
mode: 'create' | 'edit'
fields: any[]
scrollHeight?: number
}
const props = withDefaults(defineProps<Props>(), {
scrollHeight: 400
})
// Emits
const emit = defineEmits<{
'update:fields': [fields: any[]]
'add-field': [field: any]
'remove-field': [index: number]
'move-field': [index: number, direction: 'up' | 'down']
'update-field': [index: number, field: string, value: any]
}>()
// 更新字段值
const updateFieldValue = (rowIndex: number, field: string, value: any) => {
emit('update-field', rowIndex, field, value)
}
// 创建模式:表格列定义(可编辑)
const createModeColumns = computed(() => [
{
title: '序号',
width: 80,
fixed: 'left',
render: ({ rowIndex }: { rowIndex: number }) => rowIndex + 1
},
{
title: '字段名',
dataIndex: 'name',
width: 150,
fixed: 'left',
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Input, {
modelValue: record.name || '',
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'name', val),
size: 'mini',
placeholder: '字段名',
style: { width: '100%' }
})
}
},
{
title: '类型',
dataIndex: 'type',
width: 250,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const currentType = record.type || ''
const typeStr = currentType + (record.length ? `(${record.length})` : '')
const { baseType, length } = parseType(typeStr)
// 判断当前类型是否需要长度参数
const needsLen = baseType && typesNeedLength.includes(baseType.toUpperCase())
// 检查是否是自定义输入
const isCustomInput = typeStr && /[()]/.test(typeStr) && !mysqlDataTypeOptions.some(group =>
group.options.some(opt => {
const parsed = parseType(typeStr)
return parsed.baseType.toUpperCase() === opt.value.toUpperCase()
})
)
return h('div', {
style: {
display: 'flex',
gap: '4px',
width: '100%',
alignItems: 'center'
}
}, [
// 类型选择下拉框
h(Select, {
modelValue: baseType || currentType,
'onUpdate:modelValue': (val: string) => {
if (val) {
const isCustom = /[()]/.test(val) || !mysqlDataTypeOptions.some(group =>
group.options.some(opt => opt.value.toUpperCase() === val.toUpperCase())
)
if (isCustom) {
const parsed = parseType(val)
updateFieldValue(rowIndex, 'type', parsed.baseType)
if (parsed.length) {
updateFieldValue(rowIndex, 'length', parsed.length)
}
} else {
const upperVal = val.toUpperCase()
const needsLenParam = typesNeedLength.includes(upperVal)
if (needsLenParam) {
const keepLength = baseType.toUpperCase() === upperVal && length
const newType = keepLength ? formatType(upperVal, length) : upperVal
const parsed = parseType(newType)
updateFieldValue(rowIndex, 'type', parsed.baseType)
if (parsed.length) {
updateFieldValue(rowIndex, 'length', parsed.length)
} else {
updateFieldValue(rowIndex, 'length', undefined)
}
} else {
updateFieldValue(rowIndex, 'type', upperVal)
updateFieldValue(rowIndex, 'length', undefined)
}
}
} else {
updateFieldValue(rowIndex, 'type', '')
updateFieldValue(rowIndex, 'length', undefined)
}
},
allowSearch: true,
allowCreate: true,
size: 'mini',
placeholder: '选择类型',
style: { flex: needsLen ? '1' : '1 1 auto', minWidth: '100px' },
filterOption: (inputValue: string, option: any) => {
return option.label?.toLowerCase().includes(inputValue.toLowerCase()) || false
}
}, {
default: () => mysqlDataTypeOptions.map(group =>
h(Optgroup, { label: group.label, key: group.label }, {
default: () => group.options.map(opt =>
h(Option, {
label: opt.label,
value: opt.value,
key: opt.value
})
)
})
)
}),
// 长度输入框(仅当类型需要长度参数时显示)
needsLen && !isCustomInput ? h(InputGroup, {
style: { flex: '0 0 auto', width: '100px' }
}, {
prepend: () => h('span', {
style: {
padding: '0 2px',
color: 'var(--color-text-2)',
fontSize: '12px'
}
}, '('),
default: () => h(Input, {
modelValue: length || '',
'onUpdate:modelValue': (val: string) => {
const trimmedVal = val.trim()
updateFieldValue(rowIndex, 'length', trimmedVal || undefined)
},
size: 'mini',
placeholder: '32',
style: {
textAlign: 'center',
padding: '0 2px',
width: '60px'
}
}),
append: () => h('span', {
style: {
padding: '0 2px',
color: 'var(--color-text-2)',
fontSize: '12px'
}
}, ')')
}) : null
])
}
},
{
title: '允许NULL',
dataIndex: 'nullable',
width: 100,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Checkbox, {
modelValue: record.nullable !== false,
'onUpdate:modelValue': (checked: boolean) => {
updateFieldValue(rowIndex, 'nullable', checked)
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '默认值',
dataIndex: 'defaultValue',
width: 200,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const defaultValue = record.defaultValue
const isNull = defaultValue === null || defaultValue === undefined
const isEmptyString = defaultValue === ''
let currentType: 'NULL' | 'EMPTY' | 'VALUE' = 'VALUE'
if (isNull) {
currentType = 'NULL'
} else if (isEmptyString) {
currentType = 'EMPTY'
}
return h('div', { style: { display: 'flex', gap: '4px', width: '100%', alignItems: 'center' } }, [
h(Select, {
modelValue: currentType,
'onUpdate:modelValue': (val: 'NULL' | 'EMPTY' | 'VALUE') => {
if (val === 'NULL') {
updateFieldValue(rowIndex, 'defaultValue', null)
} else if (val === 'EMPTY') {
updateFieldValue(rowIndex, 'defaultValue', '')
} else if (val === 'VALUE') {
if (currentType === 'NULL' || currentType === 'EMPTY') {
updateFieldValue(rowIndex, 'defaultValue', '')
}
}
},
size: 'mini',
style: { width: '70px', flexShrink: 0 },
options: [
{ label: 'NULL', value: 'NULL' },
{ label: "''", value: 'EMPTY' },
{ label: '值', value: 'VALUE' }
]
}),
currentType === 'NULL' ? null : h(Input, {
modelValue: currentType === 'EMPTY' ? '' : String(defaultValue || ''),
'onUpdate:modelValue': (val: string) => {
if (currentType === 'EMPTY') {
if (val !== '') {
updateFieldValue(rowIndex, 'defaultValue', val)
}
} else {
updateFieldValue(rowIndex, 'defaultValue', val)
}
},
size: 'mini',
placeholder: currentType === 'EMPTY' ? "空字符串(不可编辑)" : '输入默认值',
style: { flex: 1 },
disabled: currentType === 'EMPTY'
})
])
}
},
{
title: '主键',
dataIndex: 'primaryKey',
width: 80,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Checkbox, {
modelValue: record.primaryKey || false,
'onUpdate:modelValue': (checked: boolean) => {
updateFieldValue(rowIndex, 'primaryKey', checked)
// 如果取消主键,同时取消自增
if (!checked && record.autoIncrement) {
updateFieldValue(rowIndex, 'autoIncrement', false)
}
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '自增',
dataIndex: 'autoIncrement',
width: 80,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const isIntegerType = ['TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'BIGINT'].includes(record.type?.toUpperCase())
return h(Checkbox, {
modelValue: record.autoIncrement || false,
disabled: !isIntegerType,
'onUpdate:modelValue': (checked: boolean) => {
if (checked && !record.primaryKey) {
Message.warning('自增字段必须设置为主键')
updateFieldValue(rowIndex, 'primaryKey', true)
}
updateFieldValue(rowIndex, 'autoIncrement', checked)
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '注释',
dataIndex: 'comment',
width: 200,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Input, {
modelValue: record.comment || '',
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'comment', val),
size: 'mini',
placeholder: '字段注释',
style: { width: '100%' }
})
}
},
{
title: '操作',
width: 120,
fixed: 'right',
render: ({ rowIndex }: { rowIndex: number }) => {
const totalRows = props.fields.length
return h('div', { style: { display: 'flex', gap: '4px', alignItems: 'center' } }, [
h(Button, {
type: 'text',
size: 'mini',
disabled: rowIndex === 0,
onClick: () => handleMoveField(rowIndex, 'up'),
style: { padding: '0 4px' }
}, {
default: () => h(IconUp, { style: { fontSize: '12px' } })
}),
h(Button, {
type: 'text',
size: 'mini',
disabled: rowIndex === totalRows - 1,
onClick: () => handleMoveField(rowIndex, 'down'),
style: { padding: '0 4px' }
}, {
default: () => h(IconDown, { style: { fontSize: '12px' } })
}),
h(Button, {
type: 'text',
size: 'mini',
status: 'danger',
onClick: () => handleRemoveField(rowIndex),
style: { padding: '0 4px' }
}, {
default: () => h(IconDelete, { style: { fontSize: '12px' } })
})
])
}
}
])
// 编辑模式:表格列定义(需要从 ResultPanel 中提取相关逻辑)
// 这里先简化,后续可以完善
const editModeColumns = computed(() => {
// TODO: 从 ResultPanel 中提取可编辑列定义
// 暂时返回基本列
return [
{ title: '字段名', dataIndex: 'Field', width: 150 },
{ title: '类型', dataIndex: 'Type', width: 200 },
{ title: '允许NULL', dataIndex: 'Null', width: 100 },
{ title: '默认值', dataIndex: 'Default', width: 150 },
{ title: '注释', dataIndex: 'Comment', width: 200 }
]
})
// 创建模式:添加字段
const handleAddField = () => {
const newField = {
name: '',
type: 'VARCHAR',
length: 50,
nullable: true,
defaultValue: null,
primaryKey: false,
autoIncrement: false,
comment: ''
}
emit('add-field', newField)
}
const handleRemoveField = (index: number) => {
emit('remove-field', index)
}
const handleMoveField = (index: number, direction: 'up' | 'down') => {
emit('move-field', index, direction)
}
</script>
<style scoped>
.mysql-field-list {
width: 100%;
}
.section-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.empty-tip {
padding: 20px;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
<template>
<div class="sql-editor-wrapper">
<div class="editor-toolbar">
<a-space>
<a-button type="outline" @click="handleExecute">
<template #icon>
<icon-play-arrow/>
</template>
{{ getExecuteButtonText() }} (F5)
</a-button>
<a-button type="outline" @click="handleExecuteSelected">
<template #icon>
<icon-code/>
</template>
执行选中 (Ctrl+Enter)
</a-button>
</a-space>
<a-space v-if="currentConnection">
<a-tag color="blue" size="small">
<template #icon>
<icon-storage/>
</template>
{{ currentConnection.name }}
</a-tag>
<span class="connection-info">
{{ currentConnection.host }}:{{ currentConnection.port }}
<span v-if="currentConnection.database" class="database-name">
/ {{ currentConnection.database }}
</span>
</span>
</a-space>
<span v-else class="connection-info-empty">
未选择连接
</span>
</div>
<div class="editor-container">
<div class="code-editor" ref="editorContainerRef"></div>
</div>
</div>
</template>
<script setup>
import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {Message} from '@arco-design/web-vue'
import {IconPlayArrow, IconStorage, IconCode} from '@arco-design/web-vue/es/icon'
import {EditorView, keymap, lineNumbers} from '@codemirror/view'
import {EditorState} from '@codemirror/state'
import {sql} from '@codemirror/lang-sql'
import {javascript} from '@codemirror/lang-javascript'
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands'
import {defaultHighlightStyle, syntaxHighlighting} from '@codemirror/language'
import {useTabPersistence} from '../composables/useTabPersistence'
// ==================== Props & Events ====================
const props = defineProps({
currentConnection: {
type: Object,
default: null
}
})
const emit = defineEmits(['execute', 'execute-selected', 'format'])
// 常量配置
const STORAGE_KEY_EDITOR_CONTENT = 'db-cli:editor-content'
// 标签页持久化
const tabPersistence = useTabPersistence()
// 数据库类型配置
const DB_CONFIG = {
mysql: {
language: () => sql(),
defaultContent: 'select 1;',
executeText: '执行'
},
redis: {
language: () => javascript({ jsx: false, typescript: false }),
defaultContent: 'GET key\nSET key value\nHGET hash field',
executeText: '执行命令'
},
mongo: {
language: () => javascript({ jsx: false, typescript: false }),
defaultContent: 'db.collection.find({})\n// 示例db.users.find({name: "John"})',
executeText: '执行查询'
},
mongodb: {
language: () => javascript({ jsx: false, typescript: false }),
defaultContent: 'db.collection.find({})\n// 示例db.users.find({name: "John"})',
executeText: '执行查询'
}
}
// ==================== 工具函数 ====================
const getDbType = () => props.currentConnection?.type?.toLowerCase() || 'mysql'
const getDbConfig = (dbType = null) => DB_CONFIG[dbType || getDbType()] || DB_CONFIG.mysql
const getLanguageMode = (dbType = null) => getDbConfig(dbType).language()
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
const getExecuteButtonText = () => getDbConfig().executeText
// ==================== 编辑器管理 ====================
const editorContainerRef = ref(null)
let editorView = null
let saveTimer = null
// 创建编辑器扩展
const createEditorExtensions = () => {
const dbType = getDbType()
const languageMode = getLanguageMode(dbType)
return [
EditorState.lineSeparator.of('\n'),
lineNumbers(),
history(),
languageMode,
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString()
localStorage.setItem(STORAGE_KEY_EDITOR_CONTENT, content)
}
}),
keymap.of([
...defaultKeymap,
...historyKeymap,
{
key: 'Mod-Enter',
run: () => {
handleExecuteSelected()
return true
}
},
{
key: 'F5',
run: () => {
handleExecute()
return true
}
}
]),
EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Courier New', monospace",
height: '100%',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)'
},
'.cm-content': {
padding: '12px',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)',
caretColor: 'var(--color-text-1)'
},
'.cm-editor': {
height: '100%',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
backgroundColor: 'var(--color-bg-1)'
},
'&.cm-focused': { outline: 'none' },
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
},
'&.cm-focused .cm-cursor-primary': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
},
'.cm-scroller': {
overflow: 'auto',
flex: 1,
minHeight: 0,
maxHeight: '100%',
backgroundColor: 'var(--color-bg-1)'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-2)',
border: 'none',
color: 'var(--color-text-3)'
},
'.cm-lineNumbers': { color: 'var(--color-text-3)' },
'.cm-line': { color: 'var(--color-text-1)' },
'.cm-activeLine': { backgroundColor: 'var(--color-fill-2)' },
'.cm-selectionMatch': { backgroundColor: 'var(--color-primary-light-4)' },
'.cm-dropCursor': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
}
}, { dark: false }),
EditorView.lineWrapping,
EditorView.contentAttributes.of({contenteditable: 'true'}),
]
}
// 初始化编辑器
const initEditor = async () => {
if (!editorContainerRef.value) return false
// 销毁旧编辑器
if (editorView) {
editorView.destroy()
editorView = null
}
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
const container = editorContainerRef.value
if (!container) return false
const savedContent = localStorage.getItem(STORAGE_KEY_EDITOR_CONTENT)
const initialContent = savedContent || getDefaultContent()
const state = EditorState.create({
doc: initialContent,
extensions: createEditorExtensions()
})
editorView = new EditorView({
state,
parent: container
})
return true
}
// 获取编辑器实例
const getEditor = () => editorView
// ==================== 事件处理 ====================
const validateEditor = () => {
if (!props.currentConnection) {
Message.warning('请先选择数据库连接')
return null
}
if (!editorView) {
Message.warning('编辑器未初始化')
return null
}
return editorView
}
const handleExecute = () => {
const editor = validateEditor()
if (!editor) return
const content = editor.state.doc.toString().trim()
if (!content) {
Message.warning('SQL 语句不能为空')
return
}
emit('execute', content)
}
const handleExecuteSelected = () => {
const editor = validateEditor()
if (!editor) return
const selection = editor.state.selection.main
if (!selection || selection.empty) {
Message.warning('请先选中要执行的 SQL 语句')
return
}
const content = editor.state.doc.sliceString(selection.from, selection.to).trim()
if (!content) {
Message.warning('选中的内容为空')
return
}
emit('execute-selected', content)
}
const insertSQL = async (sql) => {
const editor = getEditor()
if (!editor) {
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
const retryEditor = getEditor()
if (!retryEditor) {
await initEditor()
const newEditor = getEditor()
if (newEditor) {
insertSQL(sql)
}
return
}
insertSQL(sql)
return
}
const transaction = editor.state.update({
changes: {
from: 0,
to: editor.state.doc.length,
insert: sql
}
})
editor.dispatch(transaction)
editor.focus()
}
// ==================== 监听器 ====================
watch(() => props.currentConnection, async (newConn, oldConn) => {
// 只有数据库类型改变时才重新初始化编辑器
if (oldConn && newConn && oldConn.type !== newConn.type) {
const currentContent = editorView ? editorView.state.doc.toString() : ''
await initEditor()
// 恢复内容
if (editorView && currentContent) {
const transaction = editorView.state.update({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: currentContent
}
})
editorView.dispatch(transaction)
}
}
}, {deep: true})
// ==================== 生命周期 ====================
onBeforeUnmount(() => {
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
if (editorView) {
editorView.destroy()
editorView = null
}
})
onMounted(async () => {
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => setTimeout(resolve, 100))
await initEditor()
})
// ==================== 暴露方法 ====================
defineExpose({
insertSQL,
getTabs: () => [], // 兼容父组件,但不再支持多标签页
/**
* 保存当前编辑器状态为单个标签页
* 可用于应用关闭前保存状态
*/
saveCurrentTab: async () => {
if (!editorView) return null
const content = editorView.state.doc.toString()
const tabData = [{
id: 0, // 新标签页
title: props.currentConnection?.name ? `${props.currentConnection.name} - 查询` : '未命名查询',
content: content,
connectionId: props.currentConnection?.id || null,
order: 0
}]
const success = await tabPersistence.saveTabs(tabData)
return success ? tabData[0] : null
},
/**
* 加载保存的标签页
* 可用于应用启动时恢复状态
*/
loadSavedTabs: async () => {
const savedTabs = await tabPersistence.loadTabs()
if (savedTabs && savedTabs.length > 0) {
// 恢复第一个标签页的内容
const firstTab = savedTabs[0]
if (firstTab.content && editorView) {
const transaction = editorView.state.update({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: firstTab.content
}
})
editorView.dispatch(transaction)
}
}
return savedTabs
}
})
</script>
<style scoped>
.sql-editor-wrapper {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-container {
flex: 1;
min-height: 0;
padding: var(--spacing-md, 12px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.code-editor {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-medium, 4px);
}
/* CodeMirror 编辑器滚动支持 */
.code-editor :deep(.cm-editor) {
height: 100%;
display: flex;
flex-direction: column;
}
.code-editor :deep(.cm-scroller) {
overflow: auto;
flex: 1;
min-height: 0;
}
.editor-toolbar {
flex-shrink: 0;
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
border-bottom: 1px solid var(--color-border-2);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md, 12px);
background: var(--color-bg-1);
z-index: 10;
position: relative;
}
.connection-info {
font-size: var(--font-size-xs, 12px);
font-family: var(--font-family-mono, monospace);
color: var(--color-text-1);
}
.database-name {
margin-left: var(--spacing-sm, 8px);
color: var(--color-text-2);
}
.connection-info-empty {
font-size: var(--font-size-xs, 12px);
color: var(--color-text-3);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<div class="sql-preview-dialog">
<div class="sql-preview-header">
<span class="sql-preview-title">将执行 {{ statements.length }} {{ dbType === 'mysql' ? 'SQL' : 'MongoDB' }}语句</span>
<a-button type="text" size="small" @click="handleCopy">
<template #icon>
<icon-copy />
</template>
复制
</a-button>
</div>
<div class="sql-preview-content" ref="editorContainerRef"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCopy } from '@arco-design/web-vue/es/icon'
import { EditorView, lineNumbers } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { sql } from '@codemirror/lang-sql'
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
interface Props {
statements: string[]
dbType?: 'mysql' | 'mongo' | 'redis'
}
const props = withDefaults(defineProps<Props>(), {
dbType: 'mysql'
})
const editorContainerRef = ref<HTMLElement | null>(null)
let editorView: EditorView | null = null
// 格式化 SQL 语句(添加分号,分离编号)
const formatStatements = (statements: string[]): string => {
return statements.map((stmt, index) => {
// 确保语句末尾有分号
const trimmedStmt = stmt.trim()
const sql = trimmedStmt.endsWith(';') ? trimmedStmt : trimmedStmt + ';'
return sql
}).join('\n\n')
}
// 获取所有 SQL用于复制
const getAllSQL = (): string => {
return formatStatements(props.statements)
}
// 初始化编辑器
const initEditor = async () => {
if (!editorContainerRef.value) return
// 销毁旧编辑器
if (editorView) {
editorView.destroy()
editorView = null
}
await nextTick()
const sqlText = formatStatements(props.statements)
// 检测是否为暗色主题
const isDark = document.body.hasAttribute('arco-theme')
const state = EditorState.create({
doc: sqlText,
extensions: [
lineNumbers(),
sql(),
syntaxHighlighting(defaultHighlightStyle),
EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
height: '100%'
},
'.cm-content': {
padding: '12px',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)'
},
'.cm-editor': {
height: '100%',
backgroundColor: 'var(--color-bg-1)'
},
'.cm-scroller': {
overflow: 'auto'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-2)',
border: 'none',
color: 'var(--color-text-3)'
},
'.cm-lineNumbers': {
color: 'var(--color-text-3)'
},
'&.cm-focused': {
outline: 'none'
}
}, { dark: isDark }),
EditorView.editable.of(false), // 只读
EditorView.lineWrapping
]
})
editorView = new EditorView({
state,
parent: editorContainerRef.value
})
}
// 复制 SQL
const handleCopy = async () => {
try {
const sqlText = getAllSQL()
await navigator.clipboard.writeText(sqlText)
Message.success('SQL已复制到剪贴板')
} catch (error) {
Message.error('复制失败')
}
}
watch(() => props.statements, () => {
initEditor()
}, { deep: true })
onMounted(() => {
nextTick(() => {
initEditor()
})
})
onBeforeUnmount(() => {
if (editorView) {
editorView.destroy()
editorView = null
}
})
</script>
<style scoped>
.sql-preview-dialog {
width: 100%;
display: flex;
flex-direction: column;
}
.sql-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: var(--color-text-2);
}
.sql-preview-title {
font-size: 14px;
}
.sql-preview-content {
flex: 1;
min-height: 200px;
max-height: 400px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
background: var(--color-bg-1);
}
.sql-preview-content :deep(.cm-editor) {
height: 100%;
}
.sql-preview-content :deep(.cm-scroller) {
overflow: auto;
height: 100%;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="messages-content">
<div v-for="(msg, index) in messages" :key="index" class="message-item">
<a-tag :color="msg.type === 'error' ? 'red' : 'blue'">{{ msg.time }}</a-tag>
{{ msg.content }}
</div>
<div v-if="messages.length === 0" class="messages-empty">
<a-empty description="暂无消息"/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
messages: Array<{ type?: string; time: string; content: string }>
}>()
</script>
<style scoped>
.messages-content {
flex: 1;
padding: var(--spacing-md, 12px);
overflow-y: auto;
min-height: 0;
}
.messages-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.message-item {
margin-bottom: var(--spacing-sm, 8px);
font-size: var(--font-size-xs, 12px);
color: var(--color-text-1);
}
</style>

View File

@@ -0,0 +1,77 @@
# Result 组件重构
## 目录结构
```
result/
├── ResultTab.vue # 结果标签页容器(组合 Stats + Table/Json
├── ResultStats.vue # 统计信息栏
├── ResultTable.vue # 表格视图(含分页)
├── ResultJson.vue # JSON 视图
├── MessageLog.vue # 消息日志
├── types.ts # 类型定义
└── index.ts # 导出
```
## 组件职责
### ResultTab.vue
- 组合 ResultStats、ResultTable、ResultJson
- 管理视图模式切换(表格/JSON
- 处理加载和错误状态
### ResultStats.vue
- 显示行数、执行时间
- 视图模式切换按钮
### ResultTable.vue
- 表格展示
- 分页控制
- 高度自适应
- 单元格格式化和提示
### ResultJson.vue
- JSON 格式展示
- 语法高亮
### MessageLog.vue
- 消息列表展示
- 消息类型标识
## 使用示例
```vue
<template>
<ResultTab
:loading="loading"
:error="error"
:data="resultData"
:stats="stats"
:columns="columns"
:page="currentPage"
@re-execute-sql="handleReExecute"
/>
</template>
<script setup>
import { ResultTab } from './result'
</script>
```
## 迁移计划
### 阶段 1测试新组件
- 在 ResultPanel.vue 中引入并测试 ResultTab
- 验证功能完整性
### 阶段 2替换旧代码
- 用 ResultTab 替换 ResultPanel.vue 中的结果展示部分
- 用 MessageLog 替换消息日志部分
### 阶段 3拆分其他功能
- 将表结构相关功能拆分为 StructureTab 组件
- 将查询历史拆分为 QueryHistory 组件
### 阶段 4简化 ResultPanel.vue
- ResultPanel.vue 变成轻量的标签页容器
- 只负责标签切换和状态管理

View File

@@ -0,0 +1,73 @@
<template>
<div class="result-json-container">
<pre class="result-json" v-html="highlightedJson"></pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
data: any[]
}>()
// 转义 HTML
const escapeHtml = (str: string) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// JSON 高亮
const highlightedJson = computed(() => {
const json = JSON.stringify(props.data, null, 2)
if (!json) return ''
return escapeHtml(json)
.replace(/: ("(?:[^"\\]|\\.)*")/g, ': <span class="json-string">$1</span>')
.replace(/: (-?\d+\.?\d*(?:e[+-]?\d+)?)/gi, ': <span class="json-number">$1</span>')
.replace(/: (true|false|null)/g, ': <span class="json-boolean">$1</span>')
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
})
</script>
<style scoped>
.result-json-container {
flex: 1;
overflow: auto;
min-height: 0;
padding: var(--spacing-sm, 8px);
}
.result-json {
margin: 0;
padding: var(--spacing-md, 16px);
border-radius: var(--border-radius-medium, 6px);
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
background: linear-gradient(135deg, var(--color-bg-3) 0%, var(--color-bg-2) 100%);
color: var(--color-text-2);
border: 1px solid var(--color-border-2);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
}
.result-json :deep(.json-key) {
color: rgb(var(--arcoblue-6, 22, 93, 255));
font-weight: 500;
}
.result-json :deep(.json-string) {
color: rgb(var(--green-6, 0, 180, 42));
}
.result-json :deep(.json-number) {
color: rgb(var(--orange-6, 255, 125, 0));
}
.result-json :deep(.json-boolean) {
color: rgb(var(--purple-6, 114, 46, 209));
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="result-stats">
<a-space>
<span>{{ rowsLabel }}: {{ rowsAffected }}</span>
<a-divider type="vertical"/>
<span>执行时间: {{ executionTime }}ms</span>
<a-divider type="vertical"/>
<a-radio-group :model-value="viewMode" type="button" size="mini" @update:model-value="$emit('update:viewMode', $event)">
<a-radio value="table">表格</a-radio>
<a-radio value="json">JSON</a-radio>
</a-radio-group>
</a-space>
</div>
</template>
<script setup lang="ts">
defineProps<{
rowsLabel: string
rowsAffected: number
executionTime: number
viewMode: 'table' | 'json'
}>()
defineEmits<{
'update:viewMode': [mode: 'table' | 'json']
}>()
</script>
<style scoped>
.result-stats {
flex-shrink: 0;
margin-bottom: var(--spacing-xs, 4px);
padding: var(--spacing-xs, 4px) var(--spacing-md, 12px);
font-size: var(--font-size-xs, 12px);
color: var(--color-text-1);
}
.result-stats span {
color: var(--color-text-1);
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="result-content">
<div v-if="loading" class="result-loading">
<a-spin/>
<span>执行中...</span>
</div>
<div v-else-if="error">
<a-alert type="error" show-icon>
{{ error }}
</a-alert>
</div>
<div v-else-if="data !== null" class="result-data-wrapper">
<ResultStats
v-if="stats"
:rows-label="rowsLabel"
:rows-affected="stats.rowsAffected"
:execution-time="stats.executionTime"
:view-mode="viewMode"
@update:viewMode="viewMode = $event"
/>
<ResultTable
v-if="viewMode === 'table' && data.length > 0"
:columns="tableColumns"
:data="pagedData"
:loading="loading"
:page="page"
:can-go-next="canGoNext"
@page-change="$emit('re-execute-sql', { page: $event, pageSize: 10 })"
/>
<div v-else-if="viewMode === 'table' && data.length === 0" class="result-empty-table">
<a-empty description="查询结果为空" :image="false"/>
</div>
<ResultJson v-else-if="viewMode === 'json'" :data="data" />
</div>
<div v-else class="result-empty">
<a-empty description="暂无执行结果"/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, type ComputedRef } from 'vue'
import { Tooltip } from '@arco-design/web-vue'
import ResultStats from './ResultStats.vue'
import ResultTable from './ResultTable.vue'
import ResultJson from './ResultJson.vue'
import type { TableColumn } from './types'
const props = defineProps<{
loading: boolean
error: string
data: any[] | null
stats?: { rowsAffected: number; executionTime: number }
columns: string[]
page: number
}>()
defineEmits<{
're-execute-sql': [params: { page: number; pageSize: number }]
}>()
const viewMode = ref<'table' | 'json'>('table')
const rowsLabel = computed(() => {
if (!props.data || props.data.length === 0) return '影响行数'
return '返回行数'
})
// 表格列定义
const tableColumns: ComputedRef<TableColumn[]> = computed(() => {
if (props.columns?.length > 0) {
return props.columns.map(key => ({
title: key,
dataIndex: key,
width: 150
}))
}
if (!props.data?.length) return []
const firstRow = props.data[0] as Record<string, any>
return Object.keys(firstRow).map(key => ({
title: key,
dataIndex: key,
width: 150
}))
})
const pagedData = computed(() => props.data || [])
const canGoNext = computed(() => {
if (!props.data || props.data.length === 0) return false
return props.data.length >= 10
})
</script>
<style scoped>
.result-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
padding: var(--spacing-md, 12px);
overflow: hidden;
min-height: 0;
}
.result-loading,
.result-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.result-data-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.result-empty-table {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="result-table-container" ref="containerRef">
<a-table
:columns="columns"
:data="data"
:pagination="false"
:loading="loading"
size="mini"
:scroll="{ x: 'max-content', y: tableScrollHeight }"
:bordered="true"
class="result-table"
column-resizable
/>
<div class="custom-pagination">
<a-space>
<a-button
size="small"
:disabled="page <= 1 || loading"
@click="$emit('page-change', page - 1)"
>
上一页
</a-button>
<span style="color: var(--color-text-3); font-size: 12px;">
{{ page }} {{ data.length }}
<span v-if="!canGoNext && !loading" style="color: var(--color-text-4);">已到最后一页</span>
</span>
<a-button
size="small"
:disabled="!canGoNext || loading"
@click="$emit('page-change', page + 1)"
>
下一页
</a-button>
</a-space>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted, h } from 'vue'
import { Tooltip } from '@arco-design/web-vue'
import type { TableColumn } from './types'
const props = defineProps<{
columns: TableColumn[]
data: any[]
loading: boolean
page: number
canGoNext: boolean
}>()
const emit = defineEmits<{
'page-change': [page: number]
}>()
const containerRef = ref<HTMLElement | null>(null)
const tableScrollHeight = ref(400)
// 格式化单元格值
const formatCellValue = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return ''
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
// 渲染表格列
const renderedColumns = computed(() => {
return props.columns.map(col => ({
...col,
render: ({ record }: { record: Record<string, unknown> }) => {
const value = record[col.dataIndex]
const formattedValue = formatCellValue(value)
if (value !== null && typeof value === 'object') {
const jsonStr = JSON.stringify(value, null, 2)
return h(Tooltip, { content: jsonStr }, {
default: () => h('span', { class: 'cell-json cell-content' }, formattedValue)
})
}
return h(Tooltip, {
content: formattedValue,
disabled: !formattedValue
}, {
default: () => h('span', { class: 'cell-content' }, formattedValue)
})
}
}))
})
// 更新表格高度
const updateTableHeight = () => {
setTimeout(() => {
if (!containerRef.value) return
const container = containerRef.value
const containerHeight = container.offsetHeight
const paginationEl = container.querySelector('.custom-pagination') as HTMLElement
const paginationHeight = paginationEl ? paginationEl.offsetHeight : 40
const tableHeaderEl = container.querySelector('.arco-table-header') as HTMLElement
const tableHeaderHeight = tableHeaderEl ? tableHeaderEl.offsetHeight : 40
const availableHeight = containerHeight - paginationHeight - tableHeaderHeight - 8
tableScrollHeight.value = Math.max(100, availableHeight > 0 ? availableHeight : 400)
}, 150)
}
// 监听数据变化
watch(() => props.data, () => {
nextTick(updateTableHeight)
})
// 窗口调整
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(updateTableHeight, 100)
}
onMounted(() => {
nextTick(updateTableHeight)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
defineExpose({
updateHeight: updateTableHeight
})
</script>
<style scoped>
.result-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.result-table-container :deep(.arco-table) {
display: block;
overflow: hidden;
}
.result-table-container :deep(.arco-table-body) {
overflow-y: auto !important;
overflow-x: auto !important;
}
.custom-pagination {
flex-shrink: 0;
padding: var(--spacing-sm, 8px);
border-top: 1px solid var(--color-border-2);
display: flex;
justify-content: center;
align-items: center;
}
.result-table-container :deep(.result-table) {
font-size: var(--font-size-xs, 12px);
}
.result-table-container :deep(.result-table .arco-table-th) {
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-table-container :deep(.result-table .arco-table-td) {
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
max-width: 0;
}
.result-table-container :deep(.result-table .arco-table-td .cell-content),
.result-table-container :deep(.result-table .arco-table-td .cell-json) {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.result-table-container :deep(.result-table .arco-table-tr) {
height: 28px;
}
.result-table-container :deep(.result-table .arco-table-tbody .arco-table-tr) {
height: 28px;
}
.result-table-container :deep(.cell-json) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 3px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
cursor: help;
}
.result-table-container :deep(.cell-json:hover) {
background: var(--color-fill-3);
color: var(--color-text-2);
}
.result-table-container :deep(.cell-content) {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,10 @@
/**
* 结果展示组件导出
*/
export { default as ResultTab } from './ResultTab.vue'
export { default as ResultStats } from './ResultStats.vue'
export { default as ResultTable } from './ResultTable.vue'
export { default as ResultJson } from './ResultJson.vue'
export { default as MessageLog } from './MessageLog.vue'
export * from './types'

View File

@@ -0,0 +1,21 @@
/**
* 结果展示组件类型定义
*/
export interface TableColumn {
title: string
dataIndex: string
width?: number
render?: (params: { record: Record<string, unknown> }) => unknown
}
export interface ResultStats {
rowsAffected: number
executionTime: number
}
export interface Message {
type?: string
time: string
content: string
}

View File

@@ -0,0 +1,118 @@
# 架构迁移指南
## 新架构:事件驱动 + 单例 Store
### 核心改进
1. **事件总线 (`useEventBus.ts`)**
- 解耦组件通信
- 提供可追踪的事件流
- 支持类型安全的事件定义
2. **单例 Store (`useStructureStore.ts`)**
- 全局共享状态
- 统一状态管理
- 自动事件通知
3. **调试友好**
- 所有状态变化都有日志
- 事件触发可追踪
- 清晰的数据流
### 迁移步骤
#### 1. 旧方式(问题多多)
```ts
// ❌ 问题:状态分散,难以追踪
const structureState = useStructureState()
const { structureData, loadStructure } = structureState
// ❌ 问题:响应式传递复杂,容易丢失
<ResultPanel :structure-data="computedStructureData" />
// ❌ 问题:调试困难,不知道数据在哪里丢失
console.log('structureData:', structureData.value)
```
#### 2. 新方式(事件驱动)
```ts
// ✅ 优点:单例,全局共享
const structureStore = useStructureStore()
// ✅ 优点:直接访问,无需计算属性
<ResultPanel :structure-data="structureStore.data" />
// ✅ 优点:事件可追踪
structureStore.on('structure:data', ({ data, info }) => {
console.log('收到结构数据:', data, info)
})
```
#### 3. 组件中使用
```vue
<script setup>
import { useStructureStore } from '../composables/useStructureStore'
const store = useStructureStore()
// 直接使用 store 的状态
console.log('当前数据:', store.data.value)
console.log('当前信息:', store.info.value)
console.log('加载状态:', store.loading.value)
// 订阅事件变化(可选)
store.eventBus.on('structure:data', ({ data }) => {
console.log('数据已更新:', data)
})
</script>
<template>
<!-- 直接传递 store无需计算属性 -->
<ResultPanel
:structure-data="store.data"
:structure-info="store.info"
:structure-loading="store.loading"
:structure-error="store.error"
/>
</template>
```
### 对比
| 特性 | 旧方式 | 新方式 |
|------|--------------------------|--------------------------|
| 状态共享 | Composable 实例 | 单例 Store |
| 组件通信 | props/emit | 事件总线 |
| 响应式传递 | computed + props | 直接访问 ref |
| 调试 | 困难,日志分散 | 清晰,所有变化有日志 |
| 类型安全 | 部分 | 完全类型安全 |
| 可追踪性 | 低 | 高(事件流) |
| 解耦 | 低(依赖 props | 高(事件驱动) |
### 优势
1. **确定性**:单例确保全局只有一个实例,状态不会丢失
2. **可追踪**:所有状态变化都有日志,事件流清晰
3. **可调试**:事件总线提供完整的通信链路
4. **解耦**:组件通过事件通信,不依赖具体实现
5. **类型安全**:事件和状态都有完整的类型定义
### 适用场景
- ✅ 跨组件状态共享
- ✅ 复杂状态管理
- ✅ 需要调试的状态
- ✅ 频繁更新的状态
- ❌ 简单的本地状态(无需事件总线)
### 后续改进
1. 添加状态持久化localStorage
2. 添加状态回滚/撤销
3. 添加状态快照
4. 添加状态变更中间件
**时间:** 2026-01-03

View File

@@ -0,0 +1,116 @@
import { ref } from 'vue'
import type { Component } from 'vue'
import type { MenuItem } from '../components/ContextMenu.vue'
/**
* 右键菜单状态管理 Composable
*/
export function useContextMenu() {
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
const menuItems = ref<MenuItem[]>([])
const currentNodeData = ref<any>(null)
/**
* 显示菜单
*/
const showMenu = (event: MouseEvent, nodeData: any, items: MenuItem[]) => {
event.preventDefault()
event.stopPropagation()
menuPosition.value = {
x: event.clientX,
y: event.clientY
}
menuItems.value = items
currentNodeData.value = nodeData
menuVisible.value = true
}
/**
* 隐藏菜单
*/
const hideMenu = () => {
menuVisible.value = false
menuItems.value = []
currentNodeData.value = null
}
/**
* 处理菜单项点击
*/
const handleMenuItemClick = (item: MenuItem, emit: (event: string, data: any) => void) => {
if (item.disabled || !currentNodeData.value) return
// 根据菜单项key触发相应事件
switch (item.key) {
case 'view-structure':
emit('table-structure', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
dbType: currentNodeData.value.dbType || 'mysql',
nodeType: currentNodeData.value.type
})
break
case 'edit':
emit('connection-edit', {
connectionId: currentNodeData.value.connectionId
})
break
case 'delete':
emit('connection-delete', {
connectionId: currentNodeData.value.connectionId
})
break
case 'generate-sql':
emit('table-select', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
dbType: currentNodeData.value.dbType || 'mysql'
})
break
case 'copy-name':
// 复制名称到剪贴板
const name = currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || ''
navigator.clipboard.writeText(name)
break
case 'refresh':
// 刷新节点(通过重新加载实现)
emit('connection-refresh', {
connectionId: currentNodeData.value.connectionId,
nodeType: currentNodeData.value.type,
database: currentNodeData.value.database
})
break
case 'test':
// 测试连接
emit('connection-test', {
connectionId: currentNodeData.value.connectionId
})
break
case 'create-table':
// 创建表/集合/Key
emit('create-table', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
dbType: currentNodeData.value.dbType || 'mysql'
})
break
}
hideMenu()
}
return {
menuVisible,
menuPosition,
menuItems,
currentNodeData,
showMenu,
hideMenu,
handleMenuItemClick
}
}

View File

@@ -0,0 +1,36 @@
import { ref } from 'vue'
export function useCreateState() {
const createLoading = ref(false)
const createError = ref('')
const createInfo = ref<{
connectionId: number
database: string
dbType: 'mysql' | 'mongo' | 'redis'
} | null>(null)
const startCreate = (connectionId: number, database: string, dbType: 'mysql' | 'mongo' | 'redis') => {
createInfo.value = { connectionId, database, dbType }
createError.value = ''
}
const cancelCreate = () => {
createInfo.value = null
createError.value = ''
}
const clearCreate = () => {
createInfo.value = null
createError.value = ''
createLoading.value = false
}
return {
createLoading,
createError,
createInfo,
startCreate,
cancelCreate,
clearCreate
}
}

View File

@@ -0,0 +1,62 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { STORAGE_KEYS } from '../constants/storage'
/**
* 数据库连接管理 Composable
*/
export function useDbConnection() {
const currentConnection = ref<any>(null)
const selectedDatabase = ref('')
const showConnectionForm = ref(false)
const editingConnectionId = ref<number | null>(null)
const selectConnection = (conn: any, database?: string) => {
if (!conn?.id) return
currentConnection.value = conn
const dbName = database ?? conn.database ?? ''
selectedDatabase.value = dbName
localStorage.setItem(STORAGE_KEYS.CURRENT_CONNECTION, String(conn.id))
localStorage[dbName ? 'setItem' : 'removeItem'](STORAGE_KEYS.SELECTED_DATABASE, dbName)
}
const editConnection = (connectionId: number) => {
editingConnectionId.value = connectionId
showConnectionForm.value = true
}
const deleteConnection = (connectionId: number): boolean => {
const isCurrent = currentConnection.value?.id === connectionId
if (isCurrent) {
currentConnection.value = null
selectedDatabase.value = ''
}
return isCurrent
}
const newConnection = () => {
editingConnectionId.value = null
showConnectionForm.value = true
}
const onConnectionSuccess = (editedId: number | null) => {
showConnectionForm.value = false
editingConnectionId.value = null
if (editedId && currentConnection.value?.id === editedId) {
Message.info('连接已更新,请重新选择连接')
}
}
return {
currentConnection,
selectedDatabase,
showConnectionForm,
editingConnectionId,
selectConnection,
editConnection,
deleteConnection,
newConnection,
onConnectionSuccess
}
}

View File

@@ -0,0 +1,19 @@
import { ref } from 'vue'
import { STORAGE_KEYS } from '../constants/storage'
/**
* 编辑器状态管理 Composable
*/
export function useEditorState() {
const editorVisible = ref(
localStorage.getItem(STORAGE_KEYS.EDITOR_VISIBLE) !== 'false'
)
const toggleEditor = () => {
editorVisible.value = !editorVisible.value
localStorage.setItem(STORAGE_KEYS.EDITOR_VISIBLE, String(editorVisible.value))
}
return { editorVisible, toggleEditor }
}

View File

@@ -0,0 +1,81 @@
import { type Ref, type UnwrapRef } from 'vue'
export interface DbCliEvents {
'structure:loading': { loading: boolean }
'structure:data': { data: any; info: StructureInfo }
'structure:error': { error: string }
'structure:clear': {}
}
export interface StructureInfo {
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
}
type EventListener<T> = (payload: T) => void
class EventBus<T extends Record<string, any>> {
private listeners: Map<keyof T, Set<EventListener<any>>> = new Map()
on<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(listener)
return () => {
this.listeners.get(event)?.delete(listener)
}
}
once<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
const onceWrapper: EventListener<any> = (payload) => {
listener(payload)
this.off(event, onceWrapper)
}
return this.on(event, onceWrapper)
}
off<K extends keyof T>(event: K, listener?: EventListener<UnwrapRef<T[K]>>): void {
if (listener) {
this.listeners.get(event)?.delete(listener)
} else {
this.listeners.delete(event)
}
}
emit<K extends keyof T>(event: K, payload: UnwrapRef<T[K]>): void {
this.listeners.get(event)?.forEach(listener => {
try {
listener(payload)
} catch (error) {
console.error(`事件处理错误 [${String(event)}]:`, error)
}
})
}
clear(): void {
this.listeners.clear()
}
}
const eventBus = new EventBus<DbCliEvents>()
export function useEventBus() {
return {
on: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.on(event, listener),
once: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.once(event, listener),
off: <K extends keyof DbCliEvents>(event: K, listener?: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.off(event, listener),
emit: <K extends keyof DbCliEvents>(event: K, payload: UnwrapRef<DbCliEvents[K]>) =>
eventBus.emit(event, payload)
}
}
export { eventBus }
export type { EventBus }

View File

@@ -0,0 +1,100 @@
import type { Component } from 'vue'
import type { MenuItem } from '../components/ContextMenu.vue'
import { IconEye, IconEdit, IconDelete, IconRefresh, IconCheck, IconCode, IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
/**
* 菜单项注册表
* 根据节点类型返回对应的菜单项配置
*/
export function useMenuRegistry() {
/**
* 获取连接节点菜单项
*/
const getConnectionMenuItems = (): MenuItem[] => {
return [
{ key: 'view-structure', label: '查看结构', icon: IconEye },
{ key: 'edit', label: '编辑连接', icon: IconEdit },
{ key: 'delete', label: '删除连接', icon: IconDelete, divider: true },
{ key: 'refresh', label: '刷新', icon: IconRefresh },
{ key: 'test', label: '测试连接', icon: IconCheck }
]
}
/**
* 获取数据库节点菜单项
*/
const getDatabaseMenuItems = (dbType: string): MenuItem[] => {
const items: MenuItem[] = []
// 新建表/集合/Key
if (dbType === 'mysql') {
items.push({ key: 'create-table', label: '新建表', icon: IconPlus })
} else if (dbType === 'mongo') {
items.push({ key: 'create-table', label: '新建集合', icon: IconPlus })
} else if (dbType === 'redis') {
items.push({ key: 'create-table', label: '新建Key', icon: IconPlus })
}
items.push({ key: 'view-structure', label: '查看结构', icon: IconEye, divider: true })
if (dbType === 'mysql' || dbType === 'mongo') {
items.push({ key: 'generate-sql', label: dbType === 'mysql' ? '生成SELECT语句' : '生成find语句', icon: IconCode })
} else if (dbType === 'redis') {
items.push({ key: 'generate-sql', label: '生成KEYS命令', icon: IconCode })
}
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh, divider: true })
return items
}
/**
* 获取表节点菜单项
*/
const getTableMenuItems = (dbType: string): MenuItem[] => {
const items: MenuItem[] = [
{ key: 'view-structure', label: '查看结构', icon: IconEye }
]
if (dbType === 'mysql') {
items.push({ key: 'generate-sql', label: '生成SELECT语句', icon: IconCode })
items.push({ key: 'copy-name', label: '复制表名', icon: IconCopy, divider: true })
} else if (dbType === 'mongo') {
items.push({ key: 'generate-sql', label: '生成find语句', icon: IconCode })
items.push({ key: 'copy-name', label: '复制集合名', icon: IconCopy, divider: true })
} else if (dbType === 'redis') {
items.push({ key: 'generate-sql', label: '生成GET命令', icon: IconCode })
items.push({ key: 'copy-name', label: '复制Key名', icon: IconCopy, divider: true })
}
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh })
return items
}
/**
* 根据节点类型获取菜单项
*/
const getMenuItems = (nodeType: string, dbType?: string): MenuItem[] => {
switch (nodeType) {
case 'connection':
return getConnectionMenuItems()
case 'database':
return getDatabaseMenuItems(dbType || 'mysql')
case 'table':
case 'collection':
case 'key':
return getTableMenuItems(dbType || 'mysql')
default:
return []
}
}
return {
getMenuItems,
getConnectionMenuItems,
getDatabaseMenuItems,
getTableMenuItems
}
}

View File

@@ -0,0 +1,34 @@
import { ref } from 'vue'
const MAX_MESSAGES = 100
export interface MessageItem {
type: 'info' | 'success' | 'error' | 'warning'
content: string
time: string
}
/**
* 消息日志管理 Composable
*/
export function useMessageLog() {
const messages = ref<MessageItem[]>([])
const addMessage = (type: MessageItem['type'], content: string) => {
messages.value.unshift({
type,
content,
time: new Date().toLocaleTimeString()
})
if (messages.value.length > MAX_MESSAGES) {
messages.value = messages.value.slice(0, MAX_MESSAGES)
}
}
const clearMessages = () => {
messages.value = []
}
return { messages, addMessage, clearMessages }
}

View File

@@ -0,0 +1,92 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
export interface ResultHistoryItem {
id: number
connection_id: number
database: string
sql: string
type: string
data?: any
columns?: string[]
rows_affected: number
execution_time: number
created_at: string
}
export interface ResultHistorySearchParams {
connectionId?: number
keyword?: string
limit?: number
offset?: number
}
const handleApiError = (error: unknown, action: string): never => {
const errorMsg = error instanceof Error ? error.message : String(error) || '操作失败'
Message.error(`${action}失败: ${errorMsg}`)
throw error
}
export function useResultHistory() {
const loading = ref(false)
const histories = ref<ResultHistoryItem[]>([])
const total = ref(0)
const searchHistory = async (params: ResultHistorySearchParams = {}) => {
if (!(window as any).go?.main?.App?.GetResultHistory) {
throw new Error('Go 后端未就绪')
}
loading.value = true
try {
const result = await (window as any).go.main.App.GetResultHistory(
params.connectionId || null,
params.keyword || '',
params.limit || 20,
params.offset || 0
)
histories.value = result.items || []
total.value = result.total || 0
} catch (error: unknown) {
handleApiError(error, '查询历史记录')
} finally {
loading.value = false
}
}
const getHistoryById = async (id: number): Promise<ResultHistoryItem | null> => {
if (!(window as any).go?.main?.App?.GetResultHistoryByID) {
throw new Error('Go 后端未就绪')
}
try {
const result = await (window as any).go.main.App.GetResultHistoryByID(id)
return result || null
} catch (error: unknown) {
handleApiError(error, '查询历史记录详情')
}
}
const deleteHistory = async (id: number): Promise<boolean> => {
if (!(window as any).go?.main?.App?.DeleteResultHistory) {
throw new Error('Go 后端未就绪')
}
try {
await (window as any).go.main.App.DeleteResultHistory(id)
Message.success('删除成功')
return true
} catch (error: unknown) {
handleApiError(error, '删除历史记录')
}
}
return {
loading,
histories,
total,
searchHistory,
getHistoryById,
deleteHistory
}
}

View File

@@ -0,0 +1,112 @@
import { ref } from 'vue'
interface ResultStats {
rowsAffected: number
executionTime: number
}
interface Column {
title: string
dataIndex: string
width: number
tooltip?: boolean
}
/**
* 结果状态管理 Composable
*/
export function useResultState() {
const resultLoading = ref(false)
const resultError = ref('')
const resultData = ref<unknown>(null)
const resultMode = ref<'table' | 'json'>('table')
const resultStats = ref<ResultStats | null>(null)
const resultColumns = ref<Column[]>([])
const buildColumn = (key: string): Column => ({
title: key,
dataIndex: key,
width: 120,
tooltip: true
})
const clearResults = () => {
resultData.value = null
resultError.value = ''
resultStats.value = null
resultColumns.value = []
}
const setQueryResult = (data: unknown[], stats: ResultStats, columns?: string[]) => {
const dataArray = data ?? []
resultData.value = dataArray
resultMode.value = 'table'
resultStats.value = stats
if (columns?.length) {
resultColumns.value = columns.map(buildColumn)
} else if (dataArray.length) {
resultColumns.value = Object.keys(dataArray[0] as Record<string, any>).map(buildColumn)
} else {
resultColumns.value = []
}
}
const setUpdateResult = (stats: ResultStats) => {
resultData.value = null
resultMode.value = 'table'
resultStats.value = stats
resultColumns.value = []
}
const setCommandResult = (data: unknown, stats: ResultStats) => {
resultData.value = data
resultMode.value = 'json'
resultStats.value = stats
resultColumns.value = []
}
const setError = (error: string) => {
resultError.value = error
resultData.value = null
resultStats.value = null
resultColumns.value = []
}
// 开始加载(清空数据,用于新查询)
const startLoading = () => {
resultLoading.value = true
resultError.value = ''
resultData.value = null
resultStats.value = null
resultColumns.value = []
}
// 开始加载但保留数据(用于翻页,避免闪烁)
const startLoadingKeepData = () => {
resultLoading.value = true
resultError.value = ''
}
const stopLoading = () => {
resultLoading.value = false
}
return {
resultLoading,
resultError,
resultData,
resultMode,
resultStats,
resultColumns,
clearResults,
setQueryResult,
setUpdateResult,
setCommandResult,
setError,
startLoading,
startLoadingKeepData,
stopLoading
}
}

View File

@@ -0,0 +1,151 @@
import { inject } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { useResultState } from './useResultState'
import type { useMessageLog } from './useMessageLog'
const RESULT_STATE_KEY = Symbol('resultState')
const MESSAGE_LOG_KEY = Symbol('messageLog')
export const DbCliKeys = {
resultState: RESULT_STATE_KEY,
messageLog: MESSAGE_LOG_KEY
}
export function useSqlExecution(
resultState?: ReturnType<typeof useResultState>,
messageLog?: ReturnType<typeof useMessageLog>
) {
const injectedResultState = inject<ReturnType<typeof useResultState>>(RESULT_STATE_KEY)
const injectedMessageLog = inject<ReturnType<typeof useMessageLog>>(MESSAGE_LOG_KEY)
const finalResultState = resultState ?? injectedResultState
const finalMessageLog = messageLog ?? injectedMessageLog
if (!finalResultState || !finalMessageLog) {
throw new Error('useSqlExecution: 缺少必需的依赖')
}
const parseResultData = (data: any): any[] => {
if (data == null) return []
if (Array.isArray(data)) return data
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : (parsed ? [parsed] : [])
} catch {
return []
}
}
if (typeof data === 'object') {
if (Array.isArray(data.rows)) return data.rows
if (Array.isArray(data.data)) return data.data
return [data]
}
return [data]
}
const truncateSql = (sql: string): string =>
sql.length > 100 ? sql.slice(0, 100) + '...' : sql
// 为 SQL 添加分页(仅对查询语句)
// page=1 且 SQL 已有 LIMIT 时保留用户的 LIMIT翻页时才覆盖
const addPaginationToSQL = (sql: string, page: number, pageSize: number): string => {
if (page <= 0 || pageSize <= 0) {
return sql
}
const sqlUpper = sql.trim().toUpperCase()
// 只对 SELECT、SHOW、DESCRIBE、DESC、EXPLAIN 查询添加分页
if (!sqlUpper.startsWith('SELECT') &&
!sqlUpper.startsWith('SHOW') &&
!sqlUpper.startsWith('DESCRIBE') &&
!sqlUpper.startsWith('DESC') &&
!sqlUpper.startsWith('EXPLAIN')) {
return sql
}
const hasLimit = /\s+LIMIT\s+\d+/i.test(sql)
// 第一页且用户已写 LIMIT保留用户的 SQL 不修改
if (page === 1 && hasLimit) {
return sql
}
// 移除已有 LIMIT支持 LIMIT n、LIMIT n OFFSET m、LIMIT m,n
const strippedSql = sql.replace(/\s+LIMIT\s+\d+(?:\s*,\s*\d+)?(?:\s+OFFSET\s+\d+)?\s*;?\s*$/i, '').trim()
// 添加 LIMIT 和 OFFSET
const offset = (page - 1) * pageSize
return `${strippedSql} LIMIT ${pageSize} OFFSET ${offset}`
}
const executeSQL = async (sql: string, connection: any, database: string = '', page: number = 0, pageSize: number = 0) => {
if (!connection) {
Message.warning('请先选择数据库连接')
return
}
if (!(window as any).go?.main?.App?.ExecuteSQL) {
throw new Error('Go 后端未就绪')
}
// 翻页时保留数据避免闪烁,新查询时清空
if (page > 1) {
finalResultState.startLoadingKeepData()
} else {
finalResultState.startLoading()
}
try {
const startTime = Date.now()
const dbParam = connection.type === 'mysql' ? database : ''
// 如果是查询且需要分页,自动添加 LIMIT 和 OFFSET
const finalSQL = addPaginationToSQL(sql, page, pageSize)
const result = await (window as any).go.main.App.ExecuteSQL(
connection.id,
finalSQL,
dbParam
)
const executionTime = Date.now() - startTime
if (result.type === 'query') {
const data = parseResultData(result.data)
const stats = {
rowsAffected: data.length || result.rowsAffected || 0,
executionTime: result.executionTime ?? executionTime
}
// 统一使用表格展示,避免大数据量 JSON 渲染性能问题
finalResultState.setQueryResult(data, stats, result.columns)
Message.success(`查询成功,返回 ${stats.rowsAffected} 行数据`)
finalMessageLog.addMessage('success', `执行成功: ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
} else if (result.type === 'update') {
const stats = {
rowsAffected: result.rowsAffected ?? 0,
executionTime: result.executionTime ?? executionTime
}
finalResultState.setUpdateResult(stats)
Message.success(`执行成功,影响 ${stats.rowsAffected}`)
finalMessageLog.addMessage('success', `执行成功: 影响 ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
} else if (result.type === 'command') {
const stats = {
rowsAffected: 1,
executionTime: result.executionTime ?? executionTime
}
finalResultState.setCommandResult(result.data, stats)
Message.success('命令执行成功')
finalMessageLog.addMessage('success', `执行成功,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
}
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error)
finalResultState.setError(errorMsg)
Message.error('执行失败: ' + errorMsg)
finalMessageLog.addMessage('error', errorMsg)
} finally {
finalResultState.stopLoading()
}
}
return { executeSQL }
}

View File

@@ -0,0 +1,154 @@
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 表结构编辑状态管理 Composable
* 负责管理表结构编辑相关的状态和逻辑
*/
export function useStructureEdit() {
const isEditing = ref(false)
const editMode = ref<'view' | 'edit'>('view')
const editedColumns = ref<any[]>([])
const editedIndexes = ref<any[]>([])
const hasUnsavedChanges = computed(() => false)
const switchToViewMode = () => {
editMode.value = 'view'
isEditing.value = false
editedColumns.value = []
editedIndexes.value = []
}
const switchToEditMode = (originalColumns?: any[], originalIndexes?: any[]) => {
editMode.value = 'edit'
isEditing.value = true
editedColumns.value = originalColumns ? JSON.parse(JSON.stringify(originalColumns)) : []
editedIndexes.value = originalIndexes ? JSON.parse(JSON.stringify(originalIndexes)) : []
}
/**
* 更新表结构
*/
const updateTableStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis'
): Promise<string[]> => {
if (!(window as any).go?.main?.App?.UpdateTableStructure) {
throw new Error('Go 后端未就绪')
}
if (dbType === 'redis') {
throw new Error('Redis 不支持表结构修改')
}
const structure = dbType === 'mysql'
? { columns: editedColumns.value, indexes: editedIndexes.value }
: { indexes: editedIndexes.value }
return await (window as any).go.main.App.UpdateTableStructure(
connectionId, database, tableName, structure
)
}
/**
* 预览表结构变更
*/
const previewTableStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis'
): Promise<string[]> => {
if (!(window as any).go?.main?.App?.PreviewTableStructure) {
throw new Error('Go 后端未就绪')
}
if (dbType === 'redis') {
throw new Error('Redis 不支持表结构预览')
}
const structure = dbType === 'mysql'
? { columns: editedColumns.value, indexes: editedIndexes.value }
: { indexes: editedIndexes.value }
return await (window as any).go.main.App.PreviewTableStructure(
connectionId, database, tableName, structure
)
}
/**
* 保存结构修改
*/
const saveStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo'
) => {
try {
const sqlStatements = await updateTableStructure(connectionId, database, tableName, dbType)
Message.success('结构保存成功')
switchToViewMode()
return { success: true, sqlStatements }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('保存表结构失败: ' + errorMessage)
return { success: false, sqlStatements: [] }
}
}
/**
* 取消编辑
*/
const cancelEdit = () => switchToViewMode()
const addColumn = () => {
editedColumns.value.push({
Field: '',
Type: 'varchar(255)',
Null: 'YES',
Key: '',
Default: null,
Extra: '',
Comment: ''
})
}
const removeColumn = (index: number) => editedColumns.value.splice(index, 1)
const addIndex = () => {
editedIndexes.value.push({
Key_name: '',
Column_name: '',
Non_unique: 0,
Index_type: 'BTREE'
})
}
const removeIndex = (index: number) => editedIndexes.value.splice(index, 1)
return {
isEditing,
editMode,
editedColumns,
editedIndexes,
hasUnsavedChanges,
switchToViewMode,
switchToEditMode,
previewTableStructure,
saveStructure,
cancelEdit,
addColumn,
removeColumn,
addIndex,
removeIndex
}
}
export interface SaveStructureResult {
success: boolean
sqlStatements: string[]
}

View File

@@ -0,0 +1,159 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
/**
* 表结构状态管理 Composable
* 负责管理表结构查看相关的状态和数据
*/
export function useStructureState() {
// 状态
const structureLoading = ref(false)
const structureError = ref('')
const structureData = ref<any>(null)
const structureInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
} | null>(null)
/**
* 加载表结构
* @param connectionId 连接ID
* @param database 数据库名
* @param tableName 表名/集合名/Key名
* @param dbType 数据库类型
* @param nodeType 节点类型
*/
const loadStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
) => {
console.log('🟢 loadStructure 开始:', { connectionId, database, tableName, dbType, nodeType })
// 对于连接和数据库节点,不需要加载结构
if (nodeType === 'connection' || nodeType === 'database') {
console.log('🟡 跳过:节点类型为连接或数据库')
structureInfo.value = {
connectionId,
database,
tableName: '',
dbType,
nodeType
}
structureData.value = null
return
}
// 如果没有表名,不加载(但保留 structureInfo 用于显示提示)
if (!tableName) {
console.log('🟡 跳过:表名为空')
structureInfo.value = {
connectionId,
database,
tableName: '',
dbType,
nodeType
}
structureData.value = null
return
}
try {
structureLoading.value = true
structureError.value = ''
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('Go 后端未就绪')
}
const result = await window.go.main.App.GetTableStructure(
connectionId,
database,
tableName
)
console.log('表结构加载成功:', { connectionId, database, tableName, result })
console.log('返回数据类型:', typeof result)
console.log('返回数据 keys:', result ? Object.keys(result) : 'null')
console.log('返回数据 type 字段:', result?.type)
console.log('返回数据 columns 字段:', result?.columns)
structureData.value = result
// 确保 structureInfo 也设置了
structureInfo.value = {
connectionId,
database,
tableName,
dbType,
nodeType
}
// 确保 structureInfo 也设置了
structureInfo.value = {
connectionId,
database,
tableName,
dbType,
nodeType
}
console.log('✅ 设置完成 - structureData:', structureData.value)
console.log('✅ 设置完成 - structureInfo:', structureInfo.value)
console.log('✅ structureData 是否为 null:', structureData.value === null)
console.log('✅ structureInfo 是否为 null:', structureInfo.value === null)
} catch (error: unknown) {
console.error('加载表结构失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
structureError.value = errorMessage
Message.error('加载表结构失败: ' + errorMessage)
structureData.value = null
structureInfo.value = null
} finally {
structureLoading.value = false
}
}
/**
* 清空结构数据
*/
const clearStructure = () => {
structureData.value = null
structureInfo.value = null
structureError.value = ''
}
/**
* 刷新结构数据
*/
const refreshStructure = async () => {
if (!structureInfo.value) return
await loadStructure(
structureInfo.value.connectionId,
structureInfo.value.database,
structureInfo.value.tableName,
structureInfo.value.dbType,
structureInfo.value.nodeType
)
}
return {
// 状态
structureLoading,
structureError,
structureData,
structureInfo,
// 方法
loadStructure,
clearStructure,
refreshStructure
}
}

View File

@@ -0,0 +1,123 @@
import { ref } from 'vue'
import { useEventBus } from './useEventBus'
import type { StructureInfo } from './useEventBus'
import { STORAGE_KEYS } from '../constants/storage'
import { getTableStructure } from '@/api'
class StructureStore {
public readonly loading = ref(false)
public readonly error = ref('')
public readonly data = ref<any>(null)
public readonly info = ref<StructureInfo | null>(null)
private eventBus = useEventBus()
setLoading(loading: boolean): void {
this.loading.value = loading
this.eventBus.emit('structure:loading', { loading })
}
setError(error: string): void {
this.error.value = error
this.eventBus.emit('structure:error', { error })
}
setData(data: any, info: StructureInfo): void {
this.data.value = data
this.info.value = info
this.error.value = ''
this.loading.value = false
try {
localStorage.setItem(STORAGE_KEYS.STRUCTURE_INFO, JSON.stringify(info))
} catch {}
this.eventBus.emit('structure:data', { data, info })
}
clear(): void {
this.data.value = null
this.info.value = null
this.error.value = ''
this.loading.value = false
try {
localStorage.removeItem(STORAGE_KEYS.STRUCTURE_INFO)
} catch {}
this.eventBus.emit('structure:clear', {})
}
restoreStructureInfo(): StructureInfo | null {
try {
const saved = localStorage.getItem(STORAGE_KEYS.STRUCTURE_INFO)
return saved ? JSON.parse(saved) as StructureInfo : null
} catch {
return null
}
}
async loadStructure(
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
): Promise<void> {
// 跳过非表节点
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
this.info.value = { connectionId, database, tableName: '', dbType, nodeType }
this.data.value = null
return
}
// 检查是否切换到不同的表
const currentInfo = this.info.value
const isDifferentTable = !currentInfo ||
currentInfo.connectionId !== connectionId ||
currentInfo.database !== database ||
currentInfo.tableName !== tableName
if (isDifferentTable) {
this.data.value = null
this.error.value = ''
}
try {
this.setLoading(true)
const result = await getTableStructure(
connectionId,
database,
tableName
)
this.setData(result, { connectionId, database, tableName, dbType, nodeType })
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : '加载表结构失败'
this.setError(errorMsg)
this.data.value = null
this.info.value = null
} finally {
this.setLoading(false)
}
}
async refreshStructure(): Promise<void> {
if (!this.info.value) return
await this.loadStructure(
this.info.value.connectionId,
this.info.value.database,
this.info.value.tableName,
this.info.value.dbType,
this.info.value.nodeType
)
}
}
let structureStoreInstance: StructureStore | null = null
export function useStructureStore(): StructureStore {
if (!structureStoreInstance) {
structureStoreInstance = new StructureStore()
}
return structureStoreInstance
}
export type { StructureInfo }

View File

@@ -0,0 +1,81 @@
/**
* @deprecated 请使用 useStructureStore
*/
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
export function useStructureState() {
const structureLoading = ref(false)
const structureError = ref('')
const structureData = ref<any>(null)
const structureInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
} | null>(null)
const loadStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
) => {
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
structureInfo.value = { connectionId, database, tableName: '', dbType, nodeType }
structureData.value = null
return
}
try {
structureLoading.value = true
structureError.value = ''
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('Go 后端未就绪')
}
const result = await window.go.main.App.GetTableStructure(connectionId, database, tableName)
structureData.value = result
structureInfo.value = { connectionId, database, tableName, dbType, nodeType }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
structureError.value = errorMessage
Message.error('加载表结构失败: ' + errorMessage)
structureData.value = null
structureInfo.value = null
} finally {
structureLoading.value = false
}
}
const clearStructure = () => {
structureData.value = null
structureInfo.value = null
structureError.value = ''
}
const refreshStructure = async () => {
if (!structureInfo.value) return
await loadStructure(
structureInfo.value.connectionId,
structureInfo.value.database,
structureInfo.value.tableName,
structureInfo.value.dbType,
structureInfo.value.nodeType
)
}
return {
structureLoading,
structureError,
structureData,
structureInfo,
loadStructure,
clearStructure,
refreshStructure
}
}

View File

@@ -0,0 +1,139 @@
import { ref, nextTick } from 'vue'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
export interface TabEditorTab {
id?: number
key: string
title: string
content: string
connectionId?: number
}
export interface TabEditorOptions {
findContainer: (tabKey: string, retryCount?: number) => Promise<{ container: HTMLElement; pane?: HTMLElement } | null>
checkContainerSize: (container: HTMLElement) => Promise<void>
createExtensions: (tab: TabEditorTab) => any[]
getInitialContent: (tab: TabEditorTab) => string
onContentChange?: (tabKey: string, content: string) => void
onEditorReady?: (tabKey: string, editor: EditorView) => void
}
const INIT_DELAY = 200
export function useTabEditor(options: TabEditorOptions) {
const { findContainer, checkContainerSize, createExtensions, getInitialContent, onContentChange, onEditorReady } = options
const editorViews = ref<Map<string, EditorView>>(new Map())
const getEditor = (tabKey: string): EditorView | null => {
return editorViews.value.get(tabKey) as EditorView || null
}
const destroyEditor = (tabKey: string): void => {
const editor = editorViews.value.get(tabKey)
if (!editor) return
if (onContentChange) {
onContentChange(tabKey, editor.state.doc.toString())
}
editor.destroy()
editorViews.value.delete(tabKey)
}
const focusEditor = (editor: EditorView, delay = 0): void => {
if (!editor) return
const focus = () => {
editor.requestMeasure?.()
editor.dispatch({ effects: [] })
requestAnimationFrame(() => editor.focus())
}
delay > 0 ? setTimeout(focus, delay) : requestAnimationFrame(focus)
}
const initEditor = async (tabKey: string, tab: any, isActive: boolean, forceInit = false): Promise<boolean> => {
if (!isActive && !forceInit) return false
const existingEditor = editorViews.value.get(tabKey)
if (existingEditor instanceof EditorView) {
if (isActive) focusEditor(existingEditor, 100)
return true
}
destroyEditor(tabKey)
await nextTick()
const containerResult = await findContainer(tabKey)
if (!containerResult) return false
const { container } = containerResult
await checkContainerSize(container)
const rect = container.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) {
if (isActive) {
setTimeout(() => initEditor(tabKey, tab, isActive, forceInit), 100)
}
return false
}
const state = EditorState.create({
doc: getInitialContent(tab),
extensions: createExtensions(tab)
})
container.innerHTML = ''
const editorView = new EditorView({ state, parent: container })
editorViews.value.set(tabKey, editorView)
if (onEditorReady) onEditorReady(tabKey, editorView)
if (isActive) focusEditor(editorView, INIT_DELAY)
return true
}
const destroyAll = (): void => {
editorViews.value.forEach((_, tabKey) => destroyEditor(tabKey))
editorViews.value.clear()
}
const updateEditorContent = (tabKey: string, content: string): boolean => {
const editor = editorViews.value.get(tabKey)
if (!editor) return false
const update = () => {
const state = editor.state
if (!state?.doc) return false
if (state.doc.toString() === content) return true
try {
editor.dispatch(state.update({ changes: { from: 0, to: state.doc.length, insert: content } }))
return true
} catch {
return false
}
}
try {
return update() || update()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : ''
if (errorMessage?.includes('doesn\'t start from the previous state')) {
try { return update() } catch {}
}
return false
}
}
return {
editorViews,
getEditor,
destroyEditor,
initEditor,
focusEditor,
destroyAll,
updateEditorContent
}
}

View File

@@ -0,0 +1,67 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { saveTabs as saveTabsApi, listTabs } from '@/api'
/**
* SQL 标签页持久化 Composable
*/
export function useTabPersistence() {
const loading = ref(false)
const tabs = ref([])
/**
* 保存标签页
*/
const saveTabs = async (tabsData) => {
try {
loading.value = true
const formattedTabs = tabsData.map(tab => ({
id: tab.id || 0,
title: tab.title || '未命名查询',
content: tab.content || '',
connectionId: tab.connectionId || null,
order: tab.order || 0
}))
await saveTabsApi(formattedTabs)
tabs.value = tabsData
return true
} catch (error) {
console.error('保存标签页失败:', error)
Message.error('保存标签页失败: ' + (error.message || error))
return false
} finally {
loading.value = false
}
}
/**
* 加载标签页
*/
const loadTabs = async () => {
try {
loading.value = true
const result = await listTabs()
tabs.value = result || []
return result || []
} catch (error) {
console.error('加载标签页失败:', error)
Message.error('加载标签页失败: ' + (error.message || error))
return []
} finally {
loading.value = false
}
}
const clearTabs = () => {
tabs.value = []
}
return {
loading,
tabs,
saveTabs,
loadTabs,
clearTabs
}
}

View File

@@ -0,0 +1,24 @@
/**
* localStorage 键常量
* 统一管理所有 localStorage 键,避免重复定义
*/
export const STORAGE_KEYS = {
// SQL编辑器
ACTIVE_TAB: 'db-cli-sql-editor-active-tab',
// 数据库连接
CURRENT_CONNECTION: 'db-cli-current-connection',
SELECTED_DATABASE: 'db-cli-selected-database',
TREE_EXPANDED_KEYS: 'db-cli-tree-expanded-keys',
TREE_SELECTED_KEYS: 'db-cli-tree-selected-keys',
// 编辑器状态
EDITOR_VISIBLE: 'db-cli-editor-visible',
EDITOR_AREA_HEIGHT: 'db-cli-editor-area-height',
// 结果面板
RESULT_TAB: 'db-cli-result-tab',
// 表结构状态
STRUCTURE_INFO: 'db-cli-structure-info',
// 搜索历史
TABLE_SEARCH_HISTORY: 'db-cli-table-search-history',
TABLE_SEARCH_TEXT: 'db-cli-table-search-text'
} as const

View File

@@ -0,0 +1,966 @@
<template>
<a-layout class="db-cli-layout">
<!-- 左侧数据库列表视图 -->
<a-layout-sider :width="280" class="sidebar">
<div class="sidebar-container">
<ConnectionTree
:current-connection-id="currentConnection?.id"
@connection-select="handleConnectionSelect"
@connection-edit="handleConnectionEdit"
@connection-delete="handleConnectionDelete"
@connection-refresh="handleConnectionRefresh"
@connection-test="handleConnectionTest"
@table-select="handleTableSelect"
@table-structure="handleTableStructure"
@create-table="handleCreateTable"
@new-connection="handleNewConnection"
ref="connectionTreeRef"
/>
</div>
</a-layout-sider>
<!-- 右侧编辑器区域和结果区域 -->
<a-layout ref="mainLayoutRef" class="main-layout">
<!-- SQL编辑器区域 -->
<a-layout-content
v-if="editorVisible"
ref="editorAreaRef"
class="editor-area"
:style="editorAreaStyle"
>
<SqlEditor
:current-connection="currentConnection"
@execute="handleExecuteSQL"
@execute-selected="handleExecuteSQL"
ref="sqlEditorRef"
/>
</a-layout-content>
<!-- 编辑器/结果分隔条 -->
<div v-if="editorVisible" class="editor-result-divider" @mousedown="handleEditorResultDividerMouseDown">
<a-button
type="text"
size="mini"
class="divider-toggle-btn"
@click.stop="toggleEditor"
@mousedown.stop
title="隐藏编辑器"
>
<template #icon>
<icon-down/>
</template>
</a-button>
</div>
<!-- 编辑器隐藏时的展开按钮 -->
<div v-if="!editorVisible" class="editor-result-divider collapsed">
<a-button type="text" size="mini" class="divider-toggle-btn" @click="toggleEditor" title="显示编辑器">
<template #icon>
<icon-up/>
</template>
</a-button>
</div>
<!-- 结果展示区域 -->
<a-layout-content class="result-area">
<ResultPanel
ref="resultPanelRef"
:loading="resultLoading"
:error="resultError"
:data="(resultData as unknown[] | undefined)"
:mode="resultMode"
@re-execute-sql="handleReExecuteSQL"
:stats="(resultStats as { rowsAffected: number; executionTime: number } | undefined)"
:columns="resultColumns"
:messages="messages"
:editor-visible="editorVisible"
:structure-loading="structureLoading"
:structure-error="structureError"
:structure-data="structureData"
:structure-info="structureInfo || undefined"
:edit-mode="structureEditMode"
:edited-columns="editedColumns"
:edited-indexes="editedIndexes"
@toggle-editor="toggleEditor"
@update-columns="handleUpdateColumns"
@update-indexes="handleUpdateIndexes"
@refresh-structure="structureStore.refreshStructure"
@switch-to-edit-mode="handleSwitchToEditMode"
@switch-to-view-mode="handleSwitchToViewMode"
@save-structure="handleSaveStructure"
@cancel-edit="handleCancelEdit"
@add-column="handleAddColumn"
:create-info="createInfo"
:create-loading="createLoading"
@cancel-create="handleCancelCreate"
@create-table="handleCreateTableSubmit"
@tab-change="handleTabChange"
@view-history="handleViewHistory"
/>
</a-layout-content>
</a-layout>
<!-- 连接管理表单 -->
<ConnectionForm
v-model:visible="showConnectionForm"
:connection-id="editingConnectionId || undefined"
@success="handleConnectionSuccess"
/>
<!-- SQL 预览确认对话框 -->
<a-modal
v-model:visible="showSqlPreviewModal"
title="确认执行表结构变更"
:width="800"
:mask-closable="false"
@cancel="showSqlPreviewModal = false"
@ok="handleConfirmSqlExecute"
okText="确定执行"
cancelText="取消"
>
<SqlPreviewDialog
v-if="sqlPreviewStatements.length > 0"
:statements="sqlPreviewStatements"
:db-type="sqlPreviewDbType"
/>
</a-modal>
</a-layout>
</template>
<script setup lang="ts">
import { ref, watch, provide, computed, nextTick, onMounted, onUnmounted, h, onBeforeUpdate } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconUp, IconDown, IconCopy } from '@arco-design/web-vue/es/icon'
import SqlPreviewDialog from './components/SqlPreviewDialog.vue'
import ConnectionTree from './components/ConnectionTree.vue'
import SqlEditor from './components/SqlEditor.vue'
import ResultPanel from './components/ResultPanel.vue'
import ConnectionForm from './components/ConnectionForm.vue'
import { useDbConnection } from './composables/useDbConnection'
import { useEditorState } from './composables/useEditorState'
import { useResultState } from './composables/useResultState'
import { useMessageLog } from './composables/useMessageLog'
import { useSqlExecution, DbCliKeys } from './composables/useSqlExecution'
import { useStructureStore } from './composables/useStructureStore'
import { useStructureEdit, type SaveStructureResult } from './composables/useStructureEdit'
import { useCreateState } from './composables/useCreateState'
import { createResizeHandler } from './utils/resize'
import { STORAGE_KEYS } from './constants/storage'
import { executeQuery } from '@/api'
// 类型声明
declare global {
interface Window {
go?: {
main?: {
App?: {
GetTableStructure?: (connectionId: number, database: string, tableName: string) => Promise<any>
TestDbConnection?: (connectionId: number) => Promise<void>
ExecuteSQL?: (connectionId: number, sql: string, database?: string) => Promise<any>
}
}
}
runtime?: {
EventsOn?: (event: string, callback: () => void) => void
EventsOff?: (event: string) => void
}
}
}
// 使用 Composables
const {
currentConnection,
selectedDatabase,
showConnectionForm,
editingConnectionId,
selectConnection,
editConnection,
deleteConnection: deleteConnectionAction,
newConnection,
onConnectionSuccess
} = useDbConnection()
const { editorVisible, toggleEditor } = useEditorState()
const resultState = useResultState()
const {
resultLoading,
resultError,
resultData,
resultMode,
resultStats,
resultColumns,
clearResults
} = resultState
const messageLog = useMessageLog()
const { messages, addMessage } = messageLog
// 提供依赖注入(供子组件使用)
provide(DbCliKeys.resultState, resultState)
provide(DbCliKeys.messageLog, messageLog)
// 在当前组件中直接传递参数provide/inject 用于子组件,当前组件直接传参)
const { executeSQL } = useSqlExecution(resultState, messageLog)
// 新架构:使用单例 Store事件驱动
const structureStore = useStructureStore()
// 直接使用 Store 的状态Store 暴露的是 ref在模板中自动解包
// 为了类型安全,使用 computed 包装
const structureLoading = computed(() => structureStore.loading.value)
const structureError = computed(() => structureStore.error.value)
const structureData = computed(() => structureStore.data.value)
const structureInfo = computed(() => structureStore.info.value)
// 表结构编辑状态
const structureEdit = useStructureEdit()
const {
editMode: structureEditMode,
editedColumns,
editedIndexes,
switchToEditMode,
switchToViewMode,
previewTableStructure,
saveStructure: saveStructureEdit,
addColumn,
removeColumn
} = structureEdit
// 表创建状态
const createState = useCreateState()
const {
createInfo,
createLoading,
startCreate,
cancelCreate
} = createState
// 组件引用
const connectionTreeRef = ref<any>(null)
const sqlEditorRef = ref<any>(null)
const resultPanelRef = ref<any>(null)
const mainLayoutRef = ref<any>(null)
const editorAreaRef = ref<HTMLElement | null>(null)
// SQL 预览对话框状态
const showSqlPreviewModal = ref(false)
const sqlPreviewStatements = ref<string[]>([])
const sqlPreviewDbType = ref<'mysql' | 'mongo' | 'redis'>('mysql')
const sqlPreviewInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
} | null>(null)
// 编辑器/结果区域高度调整
const loadEditorAreaHeight = (): number => {
const saved = localStorage.getItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT)
return saved ? Number(saved) : 50
}
const editorAreaHeight = ref(loadEditorAreaHeight())
const editorAreaPixelHeight = ref<number | null>(null)
// 计算编辑器区域的样式
const editorAreaStyle = computed(() => {
if (!editorVisible.value) return {}
// 优先使用像素高度,否则使用百分比
if (editorAreaPixelHeight.value !== null) {
return { height: `${editorAreaPixelHeight.value}px` }
}
return { height: `${editorAreaHeight.value}%` }
})
// 更新编辑器区域的像素高度
const updateEditorPixelHeight = () => {
if (!mainLayoutRef.value || !editorVisible.value) {
editorAreaPixelHeight.value = null
return
}
nextTick(() => {
const mainLayoutEl = (mainLayoutRef.value as any)?.$el || mainLayoutRef.value
if (mainLayoutEl instanceof HTMLElement) {
const containerHeight = mainLayoutEl.getBoundingClientRect().height
if (containerHeight > 0) {
editorAreaPixelHeight.value = (containerHeight * editorAreaHeight.value) / 100
}
}
})
}
// 监听编辑器高度和可见性变化
watch(() => editorAreaHeight.value, updateEditorPixelHeight)
watch(() => editorVisible.value, (visible) => {
if (visible) {
updateEditorPixelHeight()
} else {
editorAreaPixelHeight.value = null
}
})
const handleEditorResultDividerMouseDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.divider-toggle-btn')) return
e.preventDefault()
e.stopPropagation()
const mainLayoutEl = mainLayoutRef.value
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
: (e.currentTarget as HTMLElement).closest('.main-layout')
if (!(mainLayoutEl instanceof HTMLElement)) return
const resizeHandler = createResizeHandler(mainLayoutEl, () => editorAreaHeight.value, {
minPercent: 20,
maxPercent: 80,
minPixels: 150,
onResize: (percentage) => {
editorAreaHeight.value = percentage
localStorage.setItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT, String(percentage))
const containerHeight = mainLayoutEl.getBoundingClientRect().height
if (containerHeight > 0) {
editorAreaPixelHeight.value = (containerHeight * percentage) / 100
}
}
})
resizeHandler(e)
}
// 导入事件类型
import type {
ConnectionSelectEvent,
ConnectionEditEvent,
ConnectionDeleteEvent,
ConnectionTestEvent,
ConnectionRefreshEvent,
TableSelectEvent,
TableStructureEvent
} from './types/events'
// 恢复表结构状态(用于页面刷新或重新进入时的状态恢复)
const restoreStructureState = async () => {
const savedInfo = structureStore.restoreStructureInfo()
if (!savedInfo?.tableName) return
// 检查连接是否匹配
if (!currentConnection.value || currentConnection.value.id !== savedInfo.connectionId) return
// 避免重复加载
if (structureStore.loading.value) return
// 如果当前已经有不同表的信息,不恢复
const currentInfo = structureStore.info.value
if (currentInfo?.tableName && currentInfo.tableName !== savedInfo.tableName) return
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
// 如果当前不是结果Tab需要切换到结构Tab
if (currentTab !== 'result' && resultPanelRef.value) {
(resultPanelRef.value as any).switchToStructureTab()
await nextTick()
await new Promise(resolve => setTimeout(resolve, 100))
}
// 再次检查加载状态切换Tab可能触发其他加载
if (structureStore.loading.value) return
// 如果当前是结果Tab不加载结构保持用户在结果Tab查看数据
if (currentTab === 'result') return
// 重新加载表结构
await structureStore.loadStructure(
savedInfo.connectionId,
savedInfo.database,
savedInfo.tableName,
savedInfo.dbType,
savedInfo.nodeType
)
}
// 连接选择
const handleConnectionSelect = async (data: ConnectionSelectEvent) => {
selectConnection(data.connection, data.database)
clearResults()
addMessage('info', `切换到连接: ${data.connection.name}${data.database ? ` (${data.database})` : ''}`)
// 连接切换后延迟恢复表结构状态(给 table-structure 事件处理时间)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 150))
await restoreStructureState()
}
// 连接编辑
const handleConnectionEdit = (data: ConnectionEditEvent) => {
editConnection(data.connectionId)
}
const handleConnectionDelete = async (data: ConnectionDeleteEvent) => {
const isCurrent = deleteConnectionAction(data.connectionId)
if (isCurrent) clearResults()
await connectionTreeRef.value?.refresh?.()
}
const handleNewConnection = () => newConnection()
const handleConnectionRefresh = async (data: ConnectionRefreshEvent) => {
await connectionTreeRef.value?.refreshNode?.(data.connectionId, data.nodeType, data.database)
}
// 测试连接
const handleConnectionTest = async (data: ConnectionTestEvent) => {
try {
await window.go?.main?.App?.TestDbConnection?.(data.connectionId)
Message.success('连接测试成功')
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('连接测试失败: ' + errorMessage)
}
}
// 生成数据库查询命令
const generateQueryCommand = (dbType: string, database: string, tableName: string, pretty: boolean = false): string => {
if (dbType === 'mongo') {
const command = {
op: "find",
collection: tableName,
filter: {},
limit: 100
}
return pretty ? JSON.stringify(command, null, 2) : JSON.stringify(command)
} else if (dbType === 'redis') {
return `GET "${tableName}"`
} else {
return `SELECT * FROM \`${database}\`.\`${tableName}\` LIMIT 10;`
}
}
// 表选择生成SQL/命令)
const handleTableSelect = (data: TableSelectEvent) => {
const dbType = data.dbType || currentConnection.value?.type || 'mysql'
const sql = generateQueryCommand(dbType, data.database, data.tableName, true)
sqlEditorRef.value?.insertSQL?.(sql)
}
// 查询表数据(用于表节点点击时自动查询数据)
const queryTableData = async (connectionId: number, database: string, tableName: string, dbType: 'mysql' | 'mongo' | 'redis' = 'mysql', nodeType: string = 'table') => {
if (!currentConnection.value || currentConnection.value.id !== connectionId) return
// 保存表信息到 structureStore以便切换到"结构"Tab时能自动加载
structureStore.info.value = { connectionId, database, tableName, dbType, nodeType }
const sql = generateQueryCommand(dbType, database, tableName)
await handleExecuteSQL(sql) // 用 handleExecuteSQL 保存原始 SQL支持翻页
}
const handleTableStructure = async (data: TableStructureEvent) => {
if (!editorVisible.value) toggleEditor()
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
if (currentTab === 'result') {
await queryTableData(data.connectionId, data.database, data.tableName, data.dbType, data.nodeType)
} else if (currentTab === 'structure') {
const currentInfo = structureStore.info.value
const isDifferentTable = !currentInfo ||
currentInfo.connectionId !== data.connectionId ||
currentInfo.database !== data.database ||
currentInfo.tableName !== data.tableName
if (isDifferentTable && structureEditMode.value === 'edit') switchToViewMode()
await structureStore.loadStructure(
data.connectionId,
data.database,
data.tableName,
data.dbType,
data.nodeType
)
}
}
// 查看历史记录(将历史记录加载到结果面板显示)
const handleViewHistory = (historyItem: any) => {
if (!historyItem) return
// 根据历史记录类型设置结果数据
if (historyItem.type === 'query') {
resultState.setQueryResult(
historyItem.data || [],
{
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
},
historyItem.columns || []
)
} else if (historyItem.type === 'update') {
resultState.setUpdateResult({
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
})
} else {
resultState.setCommandResult(
historyItem.data,
{
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
}
)
}
}
const handleTabChange = async (newTab: string, oldTab: string) => {
const structureInfo = structureStore.info.value
if (!structureInfo?.tableName) return
if (!currentConnection.value || currentConnection.value.id !== structureInfo.connectionId) return
if (newTab === 'result' && oldTab !== 'result') {
await queryTableData(
structureInfo.connectionId,
structureInfo.database,
structureInfo.tableName,
structureInfo.dbType
)
} else if (newTab === 'structure' && oldTab !== 'structure') {
const currentData = structureStore.data.value
if (!currentData || (currentData.type === 'mysql' && currentData.table !== structureInfo.tableName)) {
await structureStore.loadStructure(
structureInfo.connectionId,
structureInfo.database,
structureInfo.tableName,
structureInfo.dbType,
structureInfo.nodeType
)
}
}
}
// 开始创建表
const handleCreateTable = (data: { connectionId: number; database: string; dbType: 'mysql' | 'mongo' | 'redis' }) => {
// 如果结果面板隐藏,自动显示编辑器(这样结果面板也会显示)
if (!editorVisible.value) {
toggleEditor()
}
startCreate(data.connectionId, data.database, data.dbType)
}
// 取消创建
const handleCancelCreate = () => {
cancelCreate()
}
// 提交创建表
const handleCreateTableSubmit = async (data: { connectionId: number; database: string; tableName: string; sql: string }) => {
try {
createLoading.value = true
// 执行 CREATE TABLE SQL
const result = await executeQuery(
data.connectionId,
data.sql,
data.database
)
Message.success(`${data.tableName} 创建成功`)
addMessage('success', `${data.tableName} 创建成功`)
// 取消创建状态
cancelCreate()
// 刷新连接树(刷新表列表)
if (connectionTreeRef.value) {
await connectionTreeRef.value.refresh()
}
// 切换到结构 Tab 并加载新创建的表结构
if (resultPanelRef.value) {
(resultPanelRef.value as any).switchToStructureTab()
}
// 等待一下确保Tab切换完成
await new Promise(resolve => setTimeout(resolve, 100))
// 加载新创建的表结构
await structureStore.loadStructure(
data.connectionId,
data.database,
data.tableName,
'mysql',
'table'
)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('创建表失败: ' + errorMessage)
addMessage('error', '创建表失败: ' + errorMessage)
} finally {
createLoading.value = false
}
}
// 保存当前执行的 SQL用于分页
const currentExecutedSQL = ref('')
// 执行SQL
const handleExecuteSQL = async (sql: string, page?: number, pageSize?: number) => {
// 保存原始 SQL不包含分页信息
if (page == null && pageSize == null) {
currentExecutedSQL.value = sql
}
const resolvedPage = page ?? 1
const resolvedPageSize = pageSize ?? 10
await executeSQL(sql, currentConnection.value, selectedDatabase.value, resolvedPage, resolvedPageSize)
// 执行完成后,等待一下确保结果已经设置,然后切换到结果 tab
await nextTick()
setTimeout(() => {
if (resultPanelRef.value && (resultData.value !== null || resultStats.value !== null)) {
resultPanelRef.value.switchToResultTab()
}
}, 100)
}
// 处理分页重新执行 SQL
const handleReExecuteSQL = async (pagination: { page: number; pageSize: number }) => {
if (!currentExecutedSQL.value) {
Message.warning('无法翻页:缺少原始 SQL 语句')
return
}
await handleExecuteSQL(currentExecutedSQL.value, pagination.page, pagination.pageSize)
}
// 连接表单成功回调
const handleConnectionSuccess = async () => {
const editedId = editingConnectionId.value
// 刷新连接列表
if (connectionTreeRef.value) {
await connectionTreeRef.value?.refresh()
}
onConnectionSuccess(editedId)
}
// 表结构编辑相关处理
const handleSwitchToEditMode = () => {
const data = structureStore.data.value
const info = structureStore.info.value
if (!data || !info) {
console.warn('切换到编辑模式失败:缺少数据或信息', { data, info })
return
}
if (info.dbType === 'mysql' && (data.type === 'mysql' || !data.type)) {
const columns = data.columns || []
const indexes = data.indexes || []
if (columns.length === 0) {
console.warn('切换到编辑模式失败:字段列表为空', data)
return
}
switchToEditMode(columns, indexes)
} else if (info.dbType === 'mongo' && data.type === 'mongo') {
switchToEditMode([], data.structure?.indexes || [])
}
}
const handleSwitchToViewMode = () => {
switchToViewMode()
}
// 表结构保存处理(包含预览和用户确认流程)
const handleSaveStructure = async () => {
const info = structureStore.info.value
if (!info) return
try {
// 第一步:预览生成 SQL 语句
const previewStatements = await previewTableStructure(
info.connectionId,
info.database,
info.tableName,
info.dbType
)
// 如果没有变更,直接返回
if (previewStatements.length === 0) {
Message.info('表结构未发生变化')
return
}
// 第二步:显示确认对话框,让用户确认执行
sqlPreviewStatements.value = previewStatements
sqlPreviewDbType.value = info.dbType
sqlPreviewInfo.value = {
connectionId: info.connectionId,
database: info.database,
tableName: info.tableName,
dbType: info.dbType
}
showSqlPreviewModal.value = true
} catch (error: unknown) {
console.error('预览表结构变更失败:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('预览表结构变更失败: ' + errorMessage)
}
}
const handleCancelEdit = () => {
switchToViewMode()
}
// 确认执行 SQL
const handleConfirmSqlExecute = async () => {
if (!sqlPreviewInfo.value) return
const info = sqlPreviewInfo.value
showSqlPreviewModal.value = false
if (info.dbType === 'redis') {
Message.error('Redis 不支持表结构修改')
return
}
const result = await saveStructureEdit(
info.connectionId,
info.database,
info.tableName,
info.dbType as 'mysql' | 'mongo'
)
if (result && result.success) {
// 保存成功后刷新结构数据
await structureStore.refreshStructure()
// 在消息面板中展示生成的 SQL 语句
if (result.sqlStatements && result.sqlStatements.length > 0) {
addMessage('success', `表结构变更成功,执行了 ${result.sqlStatements.length} 条语句`)
// 为每条 SQL 语句添加消息
result.sqlStatements.forEach((sql: string, index: number) => {
addMessage('info', `[${index + 1}] ${sql}`)
})
}
}
}
// 更新编辑数据
const handleUpdateColumns = (columns: any[]) => {
editedColumns.value = columns
}
const handleUpdateIndexes = (indexes: any[]) => {
editedIndexes.value = indexes
}
// 添加字段
const handleAddColumn = () => {
addColumn()
}
// 清理本地缓存
const handleClearCache = () => {
Modal.confirm({
title: '清理本地缓存',
content: '确定要清理所有本地缓存数据吗?这将清除编辑器状态、连接状态、展开状态等所有缓存信息。',
onOk: () => {
try {
// 清理所有 localStorage 缓存
Object.values(STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key)
})
Message.success('本地缓存已清理')
// 重置连接树状态
if (connectionTreeRef.value) {
connectionTreeRef.value.refresh()
}
// 重置编辑器状态
clearResults()
} catch (error) {
Message.error('清理缓存失败: ' + (error.message || error))
}
}
})
}
// 监听容器大小变化,更新编辑器区域高度
let mainLayoutResizeObserver: ResizeObserver | null = null
// 组件挂载时的初始化工作
onMounted(async () => {
// 监听 Wails 事件(来自窗口菜单的清理缓存功能)
if (window.runtime?.EventsOn) {
window.runtime.EventsOn('clear-cache', () => {
handleClearCache()
})
}
// 初始化编辑器像素高度并监听容器大小变化
nextTick(() => {
updateEditorPixelHeight()
const mainLayoutEl = mainLayoutRef.value
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
: null
if (mainLayoutEl instanceof HTMLElement) {
mainLayoutResizeObserver = new ResizeObserver(updateEditorPixelHeight)
mainLayoutResizeObserver.observe(mainLayoutEl)
}
})
// 加载保存的标签页内容
await nextTick()
await new Promise(resolve => setTimeout(resolve, 200))
if (sqlEditorRef.value?.loadSavedTabs) {
try {
await sqlEditorRef.value.loadSavedTabs()
} catch (error) {
console.warn('加载保存的标签页失败:', error)
}
}
})
// 组件卸载时的清理工作
onUnmounted(() => {
// 取消 Wails 事件监听
if (window.runtime?.EventsOff) {
window.runtime.EventsOff('clear-cache')
}
// 清理 ResizeObserver 避免内存泄漏
if (mainLayoutResizeObserver) {
mainLayoutResizeObserver.disconnect()
mainLayoutResizeObserver = null
}
})
</script>
<style scoped>
/* 主布局容器 */
.db-cli-layout {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
}
/* 侧边栏 - 使用 Arco 设计令牌 */
.sidebar {
flex-shrink: 0;
width: 280px;
border-right: 1px solid var(--color-border-2);
overflow: hidden;
}
.sidebar-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 主布局容器 - 使用 Arco Layout */
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* 编辑器区域 - 使用 Arco Layout Content */
.editor-area {
flex: 0 0 auto !important; /* 覆盖 Arco 的 flex: auto使用固定高度 */
min-height: 150px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.editor-area :deep(.sql-editor-wrapper) {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 编辑器/结果分隔条 - 使用 Arco 设计令牌 */
.editor-result-divider {
flex-shrink: 0;
height: 4px;
background: var(--color-border-2);
cursor: row-resize;
position: relative;
transition: background-color var(--transition-duration-2) var(--transition-timing-function-ease-out);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
z-index: 10;
}
.editor-result-divider:hover {
background: var(--color-border-3);
}
.editor-result-divider.collapsed {
cursor: pointer;
height: 6px;
}
.editor-result-divider.collapsed:hover {
background: var(--color-primary-light-4);
}
.divider-toggle-btn {
position: absolute;
z-index: 10;
background: var(--color-bg-1);
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-small);
box-shadow: var(--shadow-1-down);
transition: all var(--transition-duration-2) var(--transition-timing-function-ease-out);
padding: 0;
min-width: 30px;
height: 15px;
cursor: pointer;
}
.divider-toggle-btn:hover {
background: var(--color-bg-2);
border-color: var(--color-primary-light-2);
box-shadow: var(--shadow-2-down);
transform: translateY(-1px);
}
.divider-toggle-btn:active {
transform: translateY(0);
box-shadow: var(--shadow-1-down);
}
/* 结果区域 - 使用 Arco Layout Content */
.result-area {
flex: 1;
min-height: 150px;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border-2);
overflow: hidden;
padding: 0;
}
.result-area :deep(.result-panel-wrapper) {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,6 @@
// 新架构:使用单例 Store事件驱动
const structureStore = useStructureStore()
// 直接使用 Store 的状态(无需计算属性,无需 watch
// 状态是只读的,通过 Store 方法修改
// 表结构编辑状态

View File

@@ -0,0 +1,149 @@
/**
* 数据库客户端事件类型定义
* 所有事件参数使用对象格式,确保类型安全和易于扩展
*/
// ==================== 连接相关事件 ====================
/**
* 连接选择事件
*/
export interface ConnectionSelectEvent {
connection: {
id: number
name: string
type: 'mysql' | 'mongo' | 'redis'
host: string
port: number
username: string
database?: string
[key: string]: any
}
database?: string // 可选,选中的数据库
}
/**
* 连接编辑事件
*/
export interface ConnectionEditEvent {
connectionId: number
}
/**
* 连接删除事件
*/
export interface ConnectionDeleteEvent {
connectionId: number
}
/**
* 连接刷新事件
*/
export interface ConnectionRefreshEvent {
connectionId: number
nodeType?: 'connection' | 'database' | 'table' | 'collection' | 'key' // 节点类型
database?: string // 数据库名(如果是数据库或表节点)
}
/**
* 连接测试事件
*/
export interface ConnectionTestEvent {
connectionId: number
}
// ==================== 表结构相关事件 ====================
/**
* 查看表结构事件
*/
export interface TableStructureEvent {
connectionId: number
database: string
tableName: string // 表名/集合名/Key名对于连接和数据库节点可能为空
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: 'table' | 'collection' | 'key' | 'database' | 'connection'
}
/**
* 表选择事件用于生成SQL
*/
export interface TableSelectEvent {
connectionId: number
database: string
tableName: string
dbType?: 'mysql' | 'mongo' | 'redis'
sql?: string // 可选预生成的SQL
}
// ==================== SQL执行相关事件 ====================
/**
* SQL执行事件
*/
export interface SqlExecuteEvent {
sql: string
connectionId: number
database?: string
}
/**
* SQL执行完成事件
*/
export interface SqlExecuteCompleteEvent {
result?: any
error?: string
}
// ==================== 编辑器相关事件 ====================
/**
* SQL插入事件
*/
export interface SqlInsertEvent {
sql: string
tabKey?: string // 可选指定Tab
}
/**
* Tab切换事件
*/
export interface TabSwitchEvent {
tabKey: string
}
/**
* Tab关闭事件
*/
export interface TabCloseEvent {
tabKey: string
}
// ==================== 组件事件映射 ====================
/**
* ConnectionTree 组件事件
*/
export interface ConnectionTreeEvents {
'connection-select': ConnectionSelectEvent
'connection-edit': ConnectionEditEvent
'connection-delete': ConnectionDeleteEvent
'connection-refresh': ConnectionRefreshEvent
'connection-test': ConnectionTestEvent
'table-select': TableSelectEvent
'table-structure': TableStructureEvent
'new-connection': void
}
/**
* SqlEditor 组件事件
*/
export interface SqlEditorEvents {
'execute': { sql: string }
'execute-selected': { sql: string }
'sql-insert': SqlInsertEvent
'tab-switch': TabSwitchEvent
'tab-close': TabCloseEvent
'toggle-editor': void
}

View File

@@ -0,0 +1,88 @@
// MySQL 数据类型选项
export const mysqlDataTypeOptions = [
{
label: '整数类型',
options: [
{ label: 'TINYINT', value: 'TINYINT' },
{ label: 'SMALLINT', value: 'SMALLINT' },
{ label: 'MEDIUMINT', value: 'MEDIUMINT' },
{ label: 'INT', value: 'INT' },
{ label: 'BIGINT', value: 'BIGINT' }
]
},
{
label: '浮点类型',
options: [
{ label: 'FLOAT', value: 'FLOAT' },
{ label: 'DOUBLE', value: 'DOUBLE' },
{ label: 'DECIMAL', value: 'DECIMAL' }
]
},
{
label: '字符串类型',
options: [
{ label: 'CHAR', value: 'CHAR' },
{ label: 'VARCHAR', value: 'VARCHAR' },
{ label: 'TEXT', value: 'TEXT' },
{ label: 'TINYTEXT', value: 'TINYTEXT' },
{ label: 'MEDIUMTEXT', value: 'MEDIUMTEXT' },
{ label: 'LONGTEXT', value: 'LONGTEXT' }
]
},
{
label: '日期时间类型',
options: [
{ label: 'DATE', value: 'DATE' },
{ label: 'TIME', value: 'TIME' },
{ label: 'DATETIME', value: 'DATETIME' },
{ label: 'TIMESTAMP', value: 'TIMESTAMP' },
{ label: 'YEAR', value: 'YEAR' }
]
},
{
label: '其他类型',
options: [
{ label: 'BLOB', value: 'BLOB' },
{ label: 'JSON', value: 'JSON' },
{ label: 'ENUM', value: 'ENUM' },
{ label: 'SET', value: 'SET' }
]
}
]
// 需要长度参数的类型
export const typesNeedLength = ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE']
// 解析类型字符串,提取基础类型和长度参数
export const parseType = (typeStr: string): { baseType: string; length: string | null } => {
if (!typeStr) return { baseType: '', length: null }
const match = typeStr.match(/^(\w+)(?:\((.+?)\))?$/i)
if (match) {
return {
baseType: match[1].toUpperCase(),
length: match[2] || null
}
}
return { baseType: typeStr.toUpperCase(), length: null }
}
// 格式化类型字符串
export const formatType = (baseType: string, length: string | null): string => {
if (!baseType) return ''
if (length) {
return `${baseType}(${length})`
}
return baseType
}
// 获取类型的默认长度
export const getDefaultLength = (baseType: string): string | null => {
const upperType = baseType.toUpperCase()
if (upperType === 'VARCHAR') return '255'
if (upperType === 'CHAR') return '10'
if (upperType === 'DECIMAL') return '10,2'
if (upperType === 'FLOAT') return ''
if (upperType === 'DOUBLE') return ''
return null
}

View File

@@ -0,0 +1,35 @@
export interface ResizeOptions {
minPercent?: number
maxPercent?: number
minPixels?: number
onResize?: (percentage: number) => void
}
export function createResizeHandler(
container: HTMLElement | null,
getInitialPercentage: () => number,
options: ResizeOptions = {}
): (e: MouseEvent) => void {
const { minPercent = 20, maxPercent = 80, minPixels = 150, onResize } = options
return (e: MouseEvent) => {
if (!container) return
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!container) return
const rect = container.getBoundingClientRect()
const percentage = ((moveEvent.clientY - rect.top) / rect.height) * 100
const minPercentFromPixels = (minPixels / rect.height) * 100
const clamped = Math.max(Math.max(minPercent, minPercentFromPixels), Math.min(maxPercent, percentage))
onResize?.(clamped)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
}

View File

@@ -1,8 +1,14 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true emptyOutDir: true