From 652f5e5d60aeadfc0ec2d43f737fb6632cf29653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 22 Jan 2026 18:34:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=81=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 161 +- go.mod | 20 + go.sum | 68 + internal/api/connection_api.go | 109 + internal/api/sql_api.go | 137 + internal/api/tab_api.go | 79 + internal/crypto/aes.go | 99 + internal/dbclient/mongo.go | 818 ++++++ internal/dbclient/mysql.go | 875 ++++++ internal/dbclient/pool.go | 236 ++ internal/dbclient/redis.go | 239 ++ internal/service/connection_service.go | 187 ++ internal/service/sql_exec_service.go | 467 ++++ internal/service/tab_service.go | 36 + internal/storage/connection_service.go | 165 ++ internal/storage/models/connection.go | 25 + internal/storage/models/file.go | 20 + internal/storage/models/sql_result_history.go | 24 + internal/storage/models/sql_tab.go | 21 + .../storage/repository/connection_repo.go | 70 + internal/storage/repository/result_repo.go | 110 + internal/storage/repository/tab_repo.go | 55 + internal/storage/sqlite.go | 57 + main.go | 29 +- web/package-lock.json | 779 +++++- web/package.json | 15 +- web/package.json.md5 | 2 +- web/src/App.vue | 90 +- web/src/api/connection.ts | 25 + web/src/api/database.ts | 25 + web/src/api/index.ts | 11 + web/src/api/query.ts | 21 + web/src/api/structure.ts | 19 + web/src/api/system.ts | 95 + web/src/api/tab.ts | 25 + web/src/api/types.ts | 108 + web/src/components/DeviceTest.vue | 109 +- web/src/components/ThemeToggle.vue | 50 + web/src/composables/index.ts | 8 + web/src/composables/useApiError.ts | 61 + web/src/composables/useDebounce.ts | 34 + web/src/composables/useLocalStorage.ts | 34 + web/src/composables/useTablePage.ts | 60 + web/src/composables/useTheme.ts | 78 + web/src/main.js | 7 +- web/src/style.css | 50 +- web/src/types/window.d.ts | 22 + .../db-cli/components/ConnectionForm.vue | 511 ++++ .../db-cli/components/ConnectionTree.vue | 1129 ++++++++ .../views/db-cli/components/ContextMenu.vue | 183 ++ .../views/db-cli/components/MySQLCreate.vue | 529 ++++ .../db-cli/components/MySQLFieldList.vue | 446 +++ .../views/db-cli/components/ResultPanel.vue | 2437 +++++++++++++++++ web/src/views/db-cli/components/SqlEditor.vue | 460 ++++ .../db-cli/components/SqlPreviewDialog.vue | 182 ++ .../db-cli/components/result/MessageLog.vue | 39 + .../views/db-cli/components/result/README.md | 77 + .../db-cli/components/result/ResultJson.vue | 73 + .../db-cli/components/result/ResultStats.vue | 41 + .../db-cli/components/result/ResultTab.vue | 126 + .../db-cli/components/result/ResultTable.vue | 227 ++ .../views/db-cli/components/result/index.ts | 10 + .../views/db-cli/components/result/types.ts | 21 + web/src/views/db-cli/composables/MIGRATION.md | 118 + .../db-cli/composables/useContextMenu.ts | 116 + .../db-cli/composables/useCreateState.ts | 36 + .../db-cli/composables/useDbConnection.ts | 62 + .../db-cli/composables/useEditorState.ts | 19 + .../views/db-cli/composables/useEventBus.ts | 81 + .../db-cli/composables/useMenuRegistry.ts | 100 + .../views/db-cli/composables/useMessageLog.ts | 34 + .../db-cli/composables/useResultHistory.ts | 92 + .../db-cli/composables/useResultState.ts | 112 + .../db-cli/composables/useSqlExecution.ts | 151 + .../db-cli/composables/useStructureEdit.ts | 154 ++ .../db-cli/composables/useStructureState.ts | 159 ++ .../db-cli/composables/useStructureStore.ts | 123 + .../composables/useStructureStoreLegacy.ts | 81 + .../views/db-cli/composables/useTabEditor.ts | 139 + .../db-cli/composables/useTabPersistence.js | 67 + web/src/views/db-cli/constants/storage.ts | 24 + web/src/views/db-cli/index.vue | 966 +++++++ web/src/views/db-cli/index.vue.tmp | 6 + web/src/views/db-cli/types/events.ts | 149 + web/src/views/db-cli/utils/mysqlFieldUtils.ts | 88 + web/src/views/db-cli/utils/resize.ts | 35 + web/vite.config.js | 6 + 87 files changed, 15082 insertions(+), 162 deletions(-) create mode 100644 internal/api/connection_api.go create mode 100644 internal/api/sql_api.go create mode 100644 internal/api/tab_api.go create mode 100644 internal/crypto/aes.go create mode 100644 internal/dbclient/mongo.go create mode 100644 internal/dbclient/mysql.go create mode 100644 internal/dbclient/pool.go create mode 100644 internal/dbclient/redis.go create mode 100644 internal/service/connection_service.go create mode 100644 internal/service/sql_exec_service.go create mode 100644 internal/service/tab_service.go create mode 100644 internal/storage/connection_service.go create mode 100644 internal/storage/models/connection.go create mode 100644 internal/storage/models/file.go create mode 100644 internal/storage/models/sql_result_history.go create mode 100644 internal/storage/models/sql_tab.go create mode 100644 internal/storage/repository/connection_repo.go create mode 100644 internal/storage/repository/result_repo.go create mode 100644 internal/storage/repository/tab_repo.go create mode 100644 internal/storage/sqlite.go create mode 100644 web/src/api/connection.ts create mode 100644 web/src/api/database.ts create mode 100644 web/src/api/index.ts create mode 100644 web/src/api/query.ts create mode 100644 web/src/api/structure.ts create mode 100644 web/src/api/system.ts create mode 100644 web/src/api/tab.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/components/ThemeToggle.vue create mode 100644 web/src/composables/index.ts create mode 100644 web/src/composables/useApiError.ts create mode 100644 web/src/composables/useDebounce.ts create mode 100644 web/src/composables/useLocalStorage.ts create mode 100644 web/src/composables/useTablePage.ts create mode 100644 web/src/composables/useTheme.ts create mode 100644 web/src/types/window.d.ts create mode 100644 web/src/views/db-cli/components/ConnectionForm.vue create mode 100644 web/src/views/db-cli/components/ConnectionTree.vue create mode 100644 web/src/views/db-cli/components/ContextMenu.vue create mode 100644 web/src/views/db-cli/components/MySQLCreate.vue create mode 100644 web/src/views/db-cli/components/MySQLFieldList.vue create mode 100644 web/src/views/db-cli/components/ResultPanel.vue create mode 100644 web/src/views/db-cli/components/SqlEditor.vue create mode 100644 web/src/views/db-cli/components/SqlPreviewDialog.vue create mode 100644 web/src/views/db-cli/components/result/MessageLog.vue create mode 100644 web/src/views/db-cli/components/result/README.md create mode 100644 web/src/views/db-cli/components/result/ResultJson.vue create mode 100644 web/src/views/db-cli/components/result/ResultStats.vue create mode 100644 web/src/views/db-cli/components/result/ResultTab.vue create mode 100644 web/src/views/db-cli/components/result/ResultTable.vue create mode 100644 web/src/views/db-cli/components/result/index.ts create mode 100644 web/src/views/db-cli/components/result/types.ts create mode 100644 web/src/views/db-cli/composables/MIGRATION.md create mode 100644 web/src/views/db-cli/composables/useContextMenu.ts create mode 100644 web/src/views/db-cli/composables/useCreateState.ts create mode 100644 web/src/views/db-cli/composables/useDbConnection.ts create mode 100644 web/src/views/db-cli/composables/useEditorState.ts create mode 100644 web/src/views/db-cli/composables/useEventBus.ts create mode 100644 web/src/views/db-cli/composables/useMenuRegistry.ts create mode 100644 web/src/views/db-cli/composables/useMessageLog.ts create mode 100644 web/src/views/db-cli/composables/useResultHistory.ts create mode 100644 web/src/views/db-cli/composables/useResultState.ts create mode 100644 web/src/views/db-cli/composables/useSqlExecution.ts create mode 100644 web/src/views/db-cli/composables/useStructureEdit.ts create mode 100644 web/src/views/db-cli/composables/useStructureState.ts create mode 100644 web/src/views/db-cli/composables/useStructureStore.ts create mode 100644 web/src/views/db-cli/composables/useStructureStoreLegacy.ts create mode 100644 web/src/views/db-cli/composables/useTabEditor.ts create mode 100644 web/src/views/db-cli/composables/useTabPersistence.js create mode 100644 web/src/views/db-cli/constants/storage.ts create mode 100644 web/src/views/db-cli/index.vue create mode 100644 web/src/views/db-cli/index.vue.tmp create mode 100644 web/src/views/db-cli/types/events.ts create mode 100644 web/src/views/db-cli/utils/mysqlFieldUtils.ts create mode 100644 web/src/views/db-cli/utils/resize.ts diff --git a/app.go b/app.go index 771dfe7..3559e4c 100644 --- a/app.go +++ b/app.go @@ -2,16 +2,24 @@ package main import ( "context" + "fmt" + "go-desk/internal/api" "go-desk/internal/database" "go-desk/internal/filesystem" + "go-desk/internal/storage" "go-desk/internal/system" "os" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // App 应用结构体 type App struct { - ctx context.Context - db *database.DB + ctx context.Context + db *database.DB + connectionAPI *api.ConnectionAPI + sqlAPI *api.SqlAPI + tabAPI *api.TabAPI } // NewApp 创建新的应用实例 @@ -23,13 +31,27 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx - // 初始化数据库连接 - db, err := database.Init() + // 初始化 SQLite 本地存储(核心依赖,必须成功) + // 如果失败,应用无法正常工作,应该 panic + _, err := storage.Init() if err != nil { - println("数据库连接失败:", err.Error()) - return + panic(fmt.Sprintf("SQLite 初始化失败,应用无法启动: %v", err)) + } + + // 初始化数据库连接(可选,用于测试功能) + // 失败不影响核心功能,只记录日志 + 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 查询用户列表 @@ -120,3 +142,128 @@ func splitEnv(env string) []string { } 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() +} diff --git a/go.mod b/go.mod index 385eb29..3cf45e7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module go-desk go 1.25.4 require ( + github.com/glebarez/sqlite v1.11.0 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/wailsapp/wails/v2 v2.11.0 + go.mongodb.org/mongo-driver v1.17.6 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.0 ) @@ -13,13 +16,19 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // 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/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/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jinzhu/inflection v1.0.0 // 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/gommon v0.4.2 // 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/mattn/go-colorable v0.1.13 // 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/errors v0.9.1 // 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/samber/lo v1.49.1 // 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/wailsapp/go-webview2 v1.0.22 // 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 golang.org/x/crypto v0.33.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/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 ) diff --git a/go.sum b/go.sum index 96a305a..26db2f6 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,22 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 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/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/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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 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/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 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/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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 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/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= 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/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/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-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/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-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-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-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.1.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/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-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.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/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-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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= 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= diff --git a/internal/api/connection_api.go b/internal/api/connection_api.go new file mode 100644 index 0000000..fcf0359 --- /dev/null +++ b/internal/api/connection_api.go @@ -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, + ) +} diff --git a/internal/api/sql_api.go b/internal/api/sql_api.go new file mode 100644 index 0000000..c021fba --- /dev/null +++ b/internal/api/sql_api.go @@ -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 +} diff --git a/internal/api/tab_api.go b/internal/api/tab_api.go new file mode 100644 index 0000000..ef91960 --- /dev/null +++ b/internal/api/tab_api.go @@ -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 +} diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go new file mode 100644 index 0000000..c0a2704 --- /dev/null +++ b/internal/crypto/aes.go @@ -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 +} diff --git a/internal/dbclient/mongo.go b/internal/dbclient/mongo.go new file mode 100644 index 0000000..70921f9 --- /dev/null +++ b/internal/dbclient/mongo.go @@ -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 +} diff --git a/internal/dbclient/mysql.go b/internal/dbclient/mysql.go new file mode 100644 index 0000000..edb29ce --- /dev/null +++ b/internal/dbclient/mysql.go @@ -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 执行更新 SQL(INSERT/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 +} diff --git a/internal/dbclient/pool.go b/internal/dbclient/pool.go new file mode 100644 index 0000000..c6441a2 --- /dev/null +++ b/internal/dbclient/pool.go @@ -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) +} diff --git a/internal/dbclient/redis.go b/internal/dbclient/redis.go new file mode 100644 index 0000000..d34e8dd --- /dev/null +++ b/internal/dbclient/redis.go @@ -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 +} diff --git a/internal/service/connection_service.go b/internal/service/connection_service.go new file mode 100644 index 0000000..78eec10 --- /dev/null +++ b/internal/service/connection_service.go @@ -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) + } +} diff --git a/internal/service/sql_exec_service.go b/internal/service/sql_exec_service.go new file mode 100644 index 0000000..95768c9 --- /dev/null +++ b/internal/service/sql_exec_service.go @@ -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) + } +} diff --git a/internal/service/tab_service.go b/internal/service/tab_service.go new file mode 100644 index 0000000..0bffddb --- /dev/null +++ b/internal/service/tab_service.go @@ -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) +} diff --git a/internal/storage/connection_service.go b/internal/storage/connection_service.go new file mode 100644 index 0000000..ca7f92d --- /dev/null +++ b/internal/storage/connection_service.go @@ -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) +} diff --git a/internal/storage/models/connection.go b/internal/storage/models/connection.go new file mode 100644 index 0000000..b5c6a56 --- /dev/null +++ b/internal/storage/models/connection.go @@ -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" +} diff --git a/internal/storage/models/file.go b/internal/storage/models/file.go new file mode 100644 index 0000000..b104ebf --- /dev/null +++ b/internal/storage/models/file.go @@ -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" +} diff --git a/internal/storage/models/sql_result_history.go b/internal/storage/models/sql_result_history.go new file mode 100644 index 0000000..42196ce --- /dev/null +++ b/internal/storage/models/sql_result_history.go @@ -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" +} diff --git a/internal/storage/models/sql_tab.go b/internal/storage/models/sql_tab.go new file mode 100644 index 0000000..5cc5241 --- /dev/null +++ b/internal/storage/models/sql_tab.go @@ -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" +} diff --git a/internal/storage/repository/connection_repo.go b/internal/storage/repository/connection_repo.go new file mode 100644 index 0000000..3d5458d --- /dev/null +++ b/internal/storage/repository/connection_repo.go @@ -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 +} diff --git a/internal/storage/repository/result_repo.go b/internal/storage/repository/result_repo.go new file mode 100644 index 0000000..916f010 --- /dev/null +++ b/internal/storage/repository/result_repo.go @@ -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 +} diff --git a/internal/storage/repository/tab_repo.go b/internal/storage/repository/tab_repo.go new file mode 100644 index 0000000..3cd6e86 --- /dev/null +++ b/internal/storage/repository/tab_repo.go @@ -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 +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..f56a1de --- /dev/null +++ b/internal/storage/sqlite.go @@ -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 +} diff --git a/main.go b/main.go index 6c21de2..5ab21b9 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,10 @@ package main import ( "embed" + "runtime" "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/assetserver" ) @@ -15,11 +17,32 @@ func main() { // 创建应用实例 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{ - Title: "Go Desk", - Width: 1200, - Height: 800, + Title: "Go Desk - 数据库客户端", + Width: 1400, + Height: 900, + MinWidth: 1000, + MinHeight: 600, + Menu: appMenu, AssetServer: &assetserver.Options{ Assets: assets, }, diff --git a/web/package-lock.json b/web/package-lock.json index 79c31fc..1b13164 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,11 +9,17 @@ "version": "1.0.0", "dependencies": { "@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": { - "@vitejs/plugin-vue": "^5.0.0", - "vite": "^5.0.0" + "@vitejs/plugin-vue": "^6.0.3", + "vite": "^7.3.0" } }, "node_modules/@arco-design/color": { @@ -79,8 +85,534 @@ "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": { - "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": [ "x64" ], @@ -91,13 +623,61 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "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": { "version": "4.54.0", "cpu": [ @@ -128,14 +708,19 @@ "license": "MIT" }, "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, "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "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" } }, @@ -260,6 +845,12 @@ "version": "1.0.20", "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": { "version": "3.2.3", "license": "MIT" @@ -279,7 +870,9 @@ } }, "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, "hasInstallScript": true, "license": "MIT", @@ -287,38 +880,74 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@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": { "version": "2.0.2", "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": { "version": "0.3.4", "license": "MIT" @@ -354,6 +983,20 @@ "version": "1.1.1", "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": { "version": "8.5.6", "funding": [ @@ -445,21 +1088,49 @@ "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": { - "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, "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -468,19 +1139,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -501,6 +1178,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -523,6 +1206,12 @@ "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" } } } diff --git a/web/package.json b/web/package.json index 35ecef5..60f4249 100644 --- a/web/package.json +++ b/web/package.json @@ -8,12 +8,17 @@ "preview": "vite preview" }, "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": { - "@vitejs/plugin-vue": "^5.0.0", - "vite": "^5.0.0" + "@vitejs/plugin-vue": "^6.0.3", + "vite": "^7.3.0" } } - diff --git a/web/package.json.md5 b/web/package.json.md5 index 8c1f360..4c9ae8d 100644 --- a/web/package.json.md5 +++ b/web/package.json.md5 @@ -1 +1 @@ -0e83a53f44aeb269f56998d3dfad9991 \ No newline at end of file +1fcf61f2f95666be3cda4149328a0c09 \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue index df5871e..3e054ea 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,12 +4,19 @@

Go Desk

- - + + + +
+ +
+ + +
@@ -17,17 +24,17 @@ 全部 正常 @@ -39,13 +46,13 @@ 查询 重置 @@ -57,12 +64,12 @@ + + diff --git a/web/src/composables/index.ts b/web/src/composables/index.ts new file mode 100644 index 0000000..69bff51 --- /dev/null +++ b/web/src/composables/index.ts @@ -0,0 +1,8 @@ +/** + * 全局 Composables 导出 + */ + +export * from './useLocalStorage' +export * from './useDebounce' +export * from './useTablePage' +export * from './useApiError' diff --git a/web/src/composables/useApiError.ts b/web/src/composables/useApiError.ts new file mode 100644 index 0000000..6d81366 --- /dev/null +++ b/web/src/composables/useApiError.ts @@ -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({ + 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 + } +} diff --git a/web/src/composables/useDebounce.ts b/web/src/composables/useDebounce.ts new file mode 100644 index 0000000..85b1832 --- /dev/null +++ b/web/src/composables/useDebounce.ts @@ -0,0 +1,34 @@ +/** + * Debounce composable + * 防抖函数 + */ + +import { ref, watch, type Ref, type ComputedRef } from 'vue' + +export function useDebounce(value: Ref | ComputedRef, delay: number = 300): Ref { + const debouncedValue = ref(value.value) as Ref + let timeout: ReturnType | null = null + + watch(value, (newValue) => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + debouncedValue.value = newValue + }, delay) + }) + + return debouncedValue +} + +export function debounceFn any>( + fn: T, + delay: number = 300 +): (...args: Parameters) => void { + let timeout: ReturnType | null = null + + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + fn(...args) + }, delay) + } +} diff --git a/web/src/composables/useLocalStorage.ts b/web/src/composables/useLocalStorage.ts new file mode 100644 index 0000000..a880ad5 --- /dev/null +++ b/web/src/composables/useLocalStorage.ts @@ -0,0 +1,34 @@ +/** + * LocalStorage composable + * 通用的 localStorage 操作 + */ + +import { watch, type Ref } from 'vue' + +export function useLocalStorage( + key: string, + defaultValue: T, + storage: Storage = localStorage +): [Ref, (value: T) => void, () => void] { + const stored = storage.getItem(key) + const value = ref(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] +} diff --git a/web/src/composables/useTablePage.ts b/web/src/composables/useTablePage.ts new file mode 100644 index 0000000..802a3ba --- /dev/null +++ b/web/src/composables/useTablePage.ts @@ -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 + } +} diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts new file mode 100644 index 0000000..2c41339 --- /dev/null +++ b/web/src/composables/useTheme.ts @@ -0,0 +1,78 @@ +import { ref, computed } from 'vue' + +type Theme = 'light' | 'dark' + +const THEME_STORAGE_KEY = 'app-theme' + +// 单例模式:全局共享主题状态 +const theme = ref('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 } diff --git a/web/src/main.js b/web/src/main.js index e90cd9c..6e8e8df 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,10 +1,15 @@ -import { createApp } from 'vue' +import {createApp} from 'vue' import ArcoVue from '@arco-design/web-vue' import '@arco-design/web-vue/dist/arco.css' import './style.css' import App from './App.vue' +import {initTheme} from './composables/useTheme' const app = createApp(App) app.use(ArcoVue) + +// 在应用挂载前初始化主题 +initTheme() + app.mount('#app') diff --git a/web/src/style.css b/web/src/style.css index 70bc1c8..69b384a 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,17 +1,51 @@ * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } #app { - width: 100%; - height: 100vh; + width: 100%; + 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; +} \ No newline at end of file diff --git a/web/src/types/window.d.ts b/web/src/types/window.d.ts new file mode 100644 index 0000000..4dda414 --- /dev/null +++ b/web/src/types/window.d.ts @@ -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 {} + diff --git a/web/src/views/db-cli/components/ConnectionForm.vue b/web/src/views/db-cli/components/ConnectionForm.vue new file mode 100644 index 0000000..1e80eda --- /dev/null +++ b/web/src/views/db-cli/components/ConnectionForm.vue @@ -0,0 +1,511 @@ + + + + + + diff --git a/web/src/views/db-cli/components/ConnectionTree.vue b/web/src/views/db-cli/components/ConnectionTree.vue new file mode 100644 index 0000000..4d558ac --- /dev/null +++ b/web/src/views/db-cli/components/ConnectionTree.vue @@ -0,0 +1,1129 @@ + + + + + + diff --git a/web/src/views/db-cli/components/ContextMenu.vue b/web/src/views/db-cli/components/ContextMenu.vue new file mode 100644 index 0000000..478c906 --- /dev/null +++ b/web/src/views/db-cli/components/ContextMenu.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web/src/views/db-cli/components/MySQLCreate.vue b/web/src/views/db-cli/components/MySQLCreate.vue new file mode 100644 index 0000000..d54aaf5 --- /dev/null +++ b/web/src/views/db-cli/components/MySQLCreate.vue @@ -0,0 +1,529 @@ + + + + + diff --git a/web/src/views/db-cli/components/MySQLFieldList.vue b/web/src/views/db-cli/components/MySQLFieldList.vue new file mode 100644 index 0000000..88ec68f --- /dev/null +++ b/web/src/views/db-cli/components/MySQLFieldList.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/web/src/views/db-cli/components/ResultPanel.vue b/web/src/views/db-cli/components/ResultPanel.vue new file mode 100644 index 0000000..fa92795 --- /dev/null +++ b/web/src/views/db-cli/components/ResultPanel.vue @@ -0,0 +1,2437 @@ + + + + + + diff --git a/web/src/views/db-cli/components/SqlEditor.vue b/web/src/views/db-cli/components/SqlEditor.vue new file mode 100644 index 0000000..6e443f8 --- /dev/null +++ b/web/src/views/db-cli/components/SqlEditor.vue @@ -0,0 +1,460 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/db-cli/components/SqlPreviewDialog.vue b/web/src/views/db-cli/components/SqlPreviewDialog.vue new file mode 100644 index 0000000..ed8a2de --- /dev/null +++ b/web/src/views/db-cli/components/SqlPreviewDialog.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/MessageLog.vue b/web/src/views/db-cli/components/result/MessageLog.vue new file mode 100644 index 0000000..e760814 --- /dev/null +++ b/web/src/views/db-cli/components/result/MessageLog.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/README.md b/web/src/views/db-cli/components/result/README.md new file mode 100644 index 0000000..c700c90 --- /dev/null +++ b/web/src/views/db-cli/components/result/README.md @@ -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 + + + +``` + +## 迁移计划 + +### 阶段 1:测试新组件 +- 在 ResultPanel.vue 中引入并测试 ResultTab +- 验证功能完整性 + +### 阶段 2:替换旧代码 +- 用 ResultTab 替换 ResultPanel.vue 中的结果展示部分 +- 用 MessageLog 替换消息日志部分 + +### 阶段 3:拆分其他功能 +- 将表结构相关功能拆分为 StructureTab 组件 +- 将查询历史拆分为 QueryHistory 组件 + +### 阶段 4:简化 ResultPanel.vue +- ResultPanel.vue 变成轻量的标签页容器 +- 只负责标签切换和状态管理 diff --git a/web/src/views/db-cli/components/result/ResultJson.vue b/web/src/views/db-cli/components/result/ResultJson.vue new file mode 100644 index 0000000..b19e255 --- /dev/null +++ b/web/src/views/db-cli/components/result/ResultJson.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/ResultStats.vue b/web/src/views/db-cli/components/result/ResultStats.vue new file mode 100644 index 0000000..e0b322b --- /dev/null +++ b/web/src/views/db-cli/components/result/ResultStats.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/ResultTab.vue b/web/src/views/db-cli/components/result/ResultTab.vue new file mode 100644 index 0000000..8a19344 --- /dev/null +++ b/web/src/views/db-cli/components/result/ResultTab.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/ResultTable.vue b/web/src/views/db-cli/components/result/ResultTable.vue new file mode 100644 index 0000000..6643c8e --- /dev/null +++ b/web/src/views/db-cli/components/result/ResultTable.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/web/src/views/db-cli/components/result/index.ts b/web/src/views/db-cli/components/result/index.ts new file mode 100644 index 0000000..dafece4 --- /dev/null +++ b/web/src/views/db-cli/components/result/index.ts @@ -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' diff --git a/web/src/views/db-cli/components/result/types.ts b/web/src/views/db-cli/components/result/types.ts new file mode 100644 index 0000000..69a4a79 --- /dev/null +++ b/web/src/views/db-cli/components/result/types.ts @@ -0,0 +1,21 @@ +/** + * 结果展示组件类型定义 + */ + +export interface TableColumn { + title: string + dataIndex: string + width?: number + render?: (params: { record: Record }) => unknown +} + +export interface ResultStats { + rowsAffected: number + executionTime: number +} + +export interface Message { + type?: string + time: string + content: string +} diff --git a/web/src/views/db-cli/composables/MIGRATION.md b/web/src/views/db-cli/composables/MIGRATION.md new file mode 100644 index 0000000..8d12344 --- /dev/null +++ b/web/src/views/db-cli/composables/MIGRATION.md @@ -0,0 +1,118 @@ +# 架构迁移指南 + +## 新架构:事件驱动 + 单例 Store + +### 核心改进 + +1. **事件总线 (`useEventBus.ts`)** + - 解耦组件通信 + - 提供可追踪的事件流 + - 支持类型安全的事件定义 + +2. **单例 Store (`useStructureStore.ts`)** + - 全局共享状态 + - 统一状态管理 + - 自动事件通知 + +3. **调试友好** + - 所有状态变化都有日志 + - 事件触发可追踪 + - 清晰的数据流 + +### 迁移步骤 + +#### 1. 旧方式(问题多多) + +```ts +// ❌ 问题:状态分散,难以追踪 +const structureState = useStructureState() +const { structureData, loadStructure } = structureState + +// ❌ 问题:响应式传递复杂,容易丢失 + + +// ❌ 问题:调试困难,不知道数据在哪里丢失 +console.log('structureData:', structureData.value) +``` + +#### 2. 新方式(事件驱动) + +```ts +// ✅ 优点:单例,全局共享 +const structureStore = useStructureStore() + +// ✅ 优点:直接访问,无需计算属性 + + +// ✅ 优点:事件可追踪 +structureStore.on('structure:data', ({ data, info }) => { + console.log('收到结构数据:', data, info) +}) +``` + +#### 3. 组件中使用 + +```vue + + + +``` + +### 对比 + +| 特性 | 旧方式 | 新方式 | +|------|--------------------------|--------------------------| +| 状态共享 | Composable 实例 | 单例 Store | +| 组件通信 | props/emit | 事件总线 | +| 响应式传递 | computed + props | 直接访问 ref | +| 调试 | 困难,日志分散 | 清晰,所有变化有日志 | +| 类型安全 | 部分 | 完全类型安全 | +| 可追踪性 | 低 | 高(事件流) | +| 解耦 | 低(依赖 props) | 高(事件驱动) | + +### 优势 + +1. **确定性**:单例确保全局只有一个实例,状态不会丢失 +2. **可追踪**:所有状态变化都有日志,事件流清晰 +3. **可调试**:事件总线提供完整的通信链路 +4. **解耦**:组件通过事件通信,不依赖具体实现 +5. **类型安全**:事件和状态都有完整的类型定义 + +### 适用场景 + +- ✅ 跨组件状态共享 +- ✅ 复杂状态管理 +- ✅ 需要调试的状态 +- ✅ 频繁更新的状态 +- ❌ 简单的本地状态(无需事件总线) + +### 后续改进 + +1. 添加状态持久化(localStorage) +2. 添加状态回滚/撤销 +3. 添加状态快照 +4. 添加状态变更中间件 + +**时间:** 2026-01-03 \ No newline at end of file diff --git a/web/src/views/db-cli/composables/useContextMenu.ts b/web/src/views/db-cli/composables/useContextMenu.ts new file mode 100644 index 0000000..4212455 --- /dev/null +++ b/web/src/views/db-cli/composables/useContextMenu.ts @@ -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([]) + const currentNodeData = ref(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 + } +} + diff --git a/web/src/views/db-cli/composables/useCreateState.ts b/web/src/views/db-cli/composables/useCreateState.ts new file mode 100644 index 0000000..a2e867f --- /dev/null +++ b/web/src/views/db-cli/composables/useCreateState.ts @@ -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 + } +} diff --git a/web/src/views/db-cli/composables/useDbConnection.ts b/web/src/views/db-cli/composables/useDbConnection.ts new file mode 100644 index 0000000..7f9ceaf --- /dev/null +++ b/web/src/views/db-cli/composables/useDbConnection.ts @@ -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(null) + const selectedDatabase = ref('') + const showConnectionForm = ref(false) + const editingConnectionId = ref(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 + } +} + diff --git a/web/src/views/db-cli/composables/useEditorState.ts b/web/src/views/db-cli/composables/useEditorState.ts new file mode 100644 index 0000000..09cd488 --- /dev/null +++ b/web/src/views/db-cli/composables/useEditorState.ts @@ -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 } +} + diff --git a/web/src/views/db-cli/composables/useEventBus.ts b/web/src/views/db-cli/composables/useEventBus.ts new file mode 100644 index 0000000..3b87ccc --- /dev/null +++ b/web/src/views/db-cli/composables/useEventBus.ts @@ -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 = (payload: T) => void + +class EventBus> { + private listeners: Map>> = new Map() + + on(event: K, listener: EventListener>): () => 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(event: K, listener: EventListener>): () => void { + const onceWrapper: EventListener = (payload) => { + listener(payload) + this.off(event, onceWrapper) + } + return this.on(event, onceWrapper) + } + + off(event: K, listener?: EventListener>): void { + if (listener) { + this.listeners.get(event)?.delete(listener) + } else { + this.listeners.delete(event) + } + } + + emit(event: K, payload: UnwrapRef): 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() + +export function useEventBus() { + return { + on: (event: K, listener: EventListener>) => + eventBus.on(event, listener), + once: (event: K, listener: EventListener>) => + eventBus.once(event, listener), + off: (event: K, listener?: EventListener>) => + eventBus.off(event, listener), + emit: (event: K, payload: UnwrapRef) => + eventBus.emit(event, payload) + } +} + +export { eventBus } +export type { EventBus } diff --git a/web/src/views/db-cli/composables/useMenuRegistry.ts b/web/src/views/db-cli/composables/useMenuRegistry.ts new file mode 100644 index 0000000..ef6b09b --- /dev/null +++ b/web/src/views/db-cli/composables/useMenuRegistry.ts @@ -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 + } +} + diff --git a/web/src/views/db-cli/composables/useMessageLog.ts b/web/src/views/db-cli/composables/useMessageLog.ts new file mode 100644 index 0000000..de5ee5a --- /dev/null +++ b/web/src/views/db-cli/composables/useMessageLog.ts @@ -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([]) + + 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 } +} + diff --git a/web/src/views/db-cli/composables/useResultHistory.ts b/web/src/views/db-cli/composables/useResultHistory.ts new file mode 100644 index 0000000..2c7668a --- /dev/null +++ b/web/src/views/db-cli/composables/useResultHistory.ts @@ -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([]) + 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 => { + 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 => { + 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 + } +} diff --git a/web/src/views/db-cli/composables/useResultState.ts b/web/src/views/db-cli/composables/useResultState.ts new file mode 100644 index 0000000..3829962 --- /dev/null +++ b/web/src/views/db-cli/composables/useResultState.ts @@ -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(null) + const resultMode = ref<'table' | 'json'>('table') + const resultStats = ref(null) + const resultColumns = ref([]) + + 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).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 + } +} + diff --git a/web/src/views/db-cli/composables/useSqlExecution.ts b/web/src/views/db-cli/composables/useSqlExecution.ts new file mode 100644 index 0000000..e89c9b9 --- /dev/null +++ b/web/src/views/db-cli/composables/useSqlExecution.ts @@ -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, + messageLog?: ReturnType +) { + const injectedResultState = inject>(RESULT_STATE_KEY) + const injectedMessageLog = inject>(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 } +} diff --git a/web/src/views/db-cli/composables/useStructureEdit.ts b/web/src/views/db-cli/composables/useStructureEdit.ts new file mode 100644 index 0000000..c100866 --- /dev/null +++ b/web/src/views/db-cli/composables/useStructureEdit.ts @@ -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([]) + const editedIndexes = ref([]) + + 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 => { + 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 => { + 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[] +} diff --git a/web/src/views/db-cli/composables/useStructureState.ts b/web/src/views/db-cli/composables/useStructureState.ts new file mode 100644 index 0000000..bd9963a --- /dev/null +++ b/web/src/views/db-cli/composables/useStructureState.ts @@ -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(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 + } +} + diff --git a/web/src/views/db-cli/composables/useStructureStore.ts b/web/src/views/db-cli/composables/useStructureStore.ts new file mode 100644 index 0000000..fd9a063 --- /dev/null +++ b/web/src/views/db-cli/composables/useStructureStore.ts @@ -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(null) + public readonly info = ref(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 { + // 跳过非表节点 + 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 { + 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 } diff --git a/web/src/views/db-cli/composables/useStructureStoreLegacy.ts b/web/src/views/db-cli/composables/useStructureStoreLegacy.ts new file mode 100644 index 0000000..19e0bda --- /dev/null +++ b/web/src/views/db-cli/composables/useStructureStoreLegacy.ts @@ -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(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 + } +} diff --git a/web/src/views/db-cli/composables/useTabEditor.ts b/web/src/views/db-cli/composables/useTabEditor.ts new file mode 100644 index 0000000..da2dd6f --- /dev/null +++ b/web/src/views/db-cli/composables/useTabEditor.ts @@ -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 + 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>(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 => { + 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 + } +} diff --git a/web/src/views/db-cli/composables/useTabPersistence.js b/web/src/views/db-cli/composables/useTabPersistence.js new file mode 100644 index 0000000..e6f2b30 --- /dev/null +++ b/web/src/views/db-cli/composables/useTabPersistence.js @@ -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 + } +} diff --git a/web/src/views/db-cli/constants/storage.ts b/web/src/views/db-cli/constants/storage.ts new file mode 100644 index 0000000..843734d --- /dev/null +++ b/web/src/views/db-cli/constants/storage.ts @@ -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 + diff --git a/web/src/views/db-cli/index.vue b/web/src/views/db-cli/index.vue new file mode 100644 index 0000000..7d2d668 --- /dev/null +++ b/web/src/views/db-cli/index.vue @@ -0,0 +1,966 @@ + + + + + diff --git a/web/src/views/db-cli/index.vue.tmp b/web/src/views/db-cli/index.vue.tmp new file mode 100644 index 0000000..ab93b95 --- /dev/null +++ b/web/src/views/db-cli/index.vue.tmp @@ -0,0 +1,6 @@ +// 新架构:使用单例 Store(事件驱动) +const structureStore = useStructureStore() +// 直接使用 Store 的状态(无需计算属性,无需 watch) +// 状态是只读的,通过 Store 方法修改 + +// 表结构编辑状态 \ No newline at end of file diff --git a/web/src/views/db-cli/types/events.ts b/web/src/views/db-cli/types/events.ts new file mode 100644 index 0000000..b06072c --- /dev/null +++ b/web/src/views/db-cli/types/events.ts @@ -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 +} + diff --git a/web/src/views/db-cli/utils/mysqlFieldUtils.ts b/web/src/views/db-cli/utils/mysqlFieldUtils.ts new file mode 100644 index 0000000..05dfddb --- /dev/null +++ b/web/src/views/db-cli/utils/mysqlFieldUtils.ts @@ -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 +} diff --git a/web/src/views/db-cli/utils/resize.ts b/web/src/views/db-cli/utils/resize.ts new file mode 100644 index 0000000..f05cfb1 --- /dev/null +++ b/web/src/views/db-cli/utils/resize.ts @@ -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) + } +} diff --git a/web/vite.config.js b/web/vite.config.js index 15ff6a3..332f554 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,8 +1,14 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' export default defineConfig({ plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, build: { outDir: 'dist', emptyOutDir: true